diff --git a/.gitignore b/.gitignore index f350d5b72..4796b841b 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,6 @@ port/vm-bridge/outbound/ .ai/ .amp-council/ .verify-vantages.env + +# GATE-01 per-slice Playwright JSON reports (working state; committed artifact is gate01-baseline.json) +test/e2e-browser/gate01-reports/ diff --git a/Cargo.lock b/Cargo.lock index b834c71a8..7f0ed207b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1297,6 +1297,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "freshell-extensions" +version = "0.1.0" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_json", +] + [[package]] name = "freshell-freshagent" version = "0.1.0" @@ -1310,6 +1319,7 @@ dependencies = [ "freshell-sessions", "freshell-terminal", "libc", + "serde", "serde_json", "tempfile", "tokio", @@ -1358,6 +1368,7 @@ dependencies = [ "dotenvy", "freshell-api", "freshell-codex", + "freshell-extensions", "freshell-freshagent", "freshell-platform", "freshell-protocol", diff --git a/crates/freshell-extensions/Cargo.toml b/crates/freshell-extensions/Cargo.toml new file mode 100644 index 000000000..4012f52dd --- /dev/null +++ b/crates/freshell-extensions/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "freshell-extensions" +version = "0.1.0" +description = "Extension manifest + registry substrate for the freshell Rust port (df1 EXT-01+): the STRICT freshell.json validator, ported behavior-for-behavior from the legacy zod-4 schema (server/extension-manifest.ts) and pinned by a generated differential oracle (crates/freshell-extensions/fixtures/manifest-oracle.json, produced by port/contract/generate-manifest-oracle.ts). Deliberately I/O-free: callers hand in manifest file TEXT, receive either the fully-typed manifest (defaults materialized) or zod-parity issues." +edition.workspace = true +rust-version.workspace = true +publish.workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +# Insertion-ordered maps — the client renders content-schema forms in manifest +# text order (JS object insertion order on the legacy side). Version matches +# the tree (serde_json preserve_order already pulls indexmap 2). +indexmap = { version = "2", features = ["serde"] } diff --git a/crates/freshell-extensions/fixtures/manifest-oracle.json b/crates/freshell-extensions/fixtures/manifest-oracle.json new file mode 100644 index 000000000..0fa7857ad --- /dev/null +++ b/crates/freshell-extensions/fixtures/manifest-oracle.json @@ -0,0 +1,2552 @@ +{ + "meta": { + "generator": "port/contract/generate-manifest-oracle.ts", + "schemaSource": "server/extension-manifest.ts (UNMODIFIED legacy zod schema)", + "zodVersion": "4.3.6", + "note": "GENERATED — do not edit by hand. Regenerate: npx tsx port/contract/generate-manifest-oracle.ts" + }, + "cases": [ + { + "name": "valid-server-manifest", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true}}", + "expected": { + "success": true, + "data": { + "name": "test-server-ext", + "version": "0.1.0", + "label": "Test Server Extension", + "description": "A test server extension", + "category": "server", + "server": { + "command": "node", + "args": [ + "dist/index.js" + ], + "readyPattern": "Listening on", + "readyTimeout": 10000, + "singleton": true + } + } + } + }, + { + "name": "valid-client-manifest", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"}}", + "expected": { + "success": true, + "data": { + "name": "test-client-ext", + "version": "1.0.0", + "label": "Test Client Extension", + "description": "A test client extension", + "category": "client", + "client": { + "entry": "./dist/index.html" + } + } + } + }, + { + "name": "valid-cli-manifest", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"lazygit\"}}", + "expected": { + "success": true, + "data": { + "name": "test-cli-ext", + "version": "0.2.0", + "label": "Test CLI Extension", + "description": "A test CLI extension", + "category": "cli", + "cli": { + "command": "lazygit", + "args": [] + } + } + } + }, + { + "name": "cli-full-launch-templates-and-permission-mapping", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"opencode\",\"resumeArgs\":[\"--session\",\"{{sessionId}}\"],\"createSessionArgs\":[\"--session-id\",\"{{sessionId}}\"],\"modelArgs\":[\"--model\",\"{{model}}\"],\"sandboxArgs\":[\"--sandbox\",\"{{sandbox}}\"],\"permissionModeArgs\":[\"--permission-mode\",\"{{permissionMode}}\"],\"permissionModeEnvVar\":\"AGENT_PERMISSION_MODE\",\"permissionModeValues\":{\"plan\":\"{\\\"edit\\\":\\\"ask\\\",\\\"bash\\\":\\\"ask\\\"}\"},\"supportsPermissionMode\":true,\"supportsModel\":true,\"supportsSandbox\":true}}", + "expected": { + "success": true, + "data": { + "name": "test-cli-ext", + "version": "0.2.0", + "label": "Test CLI Extension", + "description": "A test CLI extension", + "category": "cli", + "cli": { + "command": "opencode", + "args": [], + "resumeArgs": [ + "--session", + "{{sessionId}}" + ], + "createSessionArgs": [ + "--session-id", + "{{sessionId}}" + ], + "modelArgs": [ + "--model", + "{{model}}" + ], + "sandboxArgs": [ + "--sandbox", + "{{sandbox}}" + ], + "permissionModeArgs": [ + "--permission-mode", + "{{permissionMode}}" + ], + "permissionModeEnvVar": "AGENT_PERMISSION_MODE", + "permissionModeValues": { + "plan": "{\"edit\":\"ask\",\"bash\":\"ask\"}" + }, + "supportsPermissionMode": true, + "supportsModel": true, + "supportsSandbox": true + } + } + } + }, + { + "name": "optional-fields-icon-url-contentschema-picker", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true},\"icon\":\"./icon.svg\",\"url\":\"/run/{{runId}}\",\"contentSchema\":{\"runId\":{\"type\":\"string\",\"label\":\"Run ID\",\"required\":true}},\"picker\":{\"shortcut\":\"K\",\"group\":\"tools\"}}", + "expected": { + "success": true, + "data": { + "name": "test-server-ext", + "version": "0.1.0", + "label": "Test Server Extension", + "description": "A test server extension", + "category": "server", + "icon": "./icon.svg", + "url": "/run/{{runId}}", + "contentSchema": { + "runId": { + "type": "string", + "label": "Run ID", + "required": true + } + }, + "picker": { + "shortcut": "K", + "group": "tools" + }, + "server": { + "command": "node", + "args": [ + "dist/index.js" + ], + "readyPattern": "Listening on", + "readyTimeout": 10000, + "singleton": true + } + } + } + }, + { + "name": "picker-shortcut-only", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"picker\":{\"shortcut\":\"C\"}}", + "expected": { + "success": true, + "data": { + "name": "test-client-ext", + "version": "1.0.0", + "label": "Test Client Extension", + "description": "A test client extension", + "category": "client", + "picker": { + "shortcut": "C" + }, + "client": { + "entry": "./dist/index.html" + } + } + } + }, + { + "name": "picker-group-only", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"picker\":{\"group\":\"viewers\"}}", + "expected": { + "success": true, + "data": { + "name": "test-client-ext", + "version": "1.0.0", + "label": "Test Client Extension", + "description": "A test client extension", + "category": "client", + "picker": { + "group": "viewers" + }, + "client": { + "entry": "./dist/index.html" + } + } + } + }, + { + "name": "picker-empty-object", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"picker\":{}}", + "expected": { + "success": true, + "data": { + "name": "test-client-ext", + "version": "1.0.0", + "label": "Test Client Extension", + "description": "A test client extension", + "category": "client", + "picker": {}, + "client": { + "entry": "./dist/index.html" + } + } + } + }, + { + "name": "server-env-template-vars", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true,\"env\":{\"PORT\":\"{{port}}\",\"RUNS_DIR\":\"{{runsDir}}\"}}}", + "expected": { + "success": true, + "data": { + "name": "test-server-ext", + "version": "0.1.0", + "label": "Test Server Extension", + "description": "A test server extension", + "category": "server", + "server": { + "command": "node", + "args": [ + "dist/index.js" + ], + "env": { + "PORT": "{{port}}", + "RUNS_DIR": "{{runsDir}}" + }, + "readyPattern": "Listening on", + "readyTimeout": 10000, + "singleton": true + } + } + } + }, + { + "name": "server-health-check", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true,\"healthCheck\":\"/api/health\"}}", + "expected": { + "success": true, + "data": { + "name": "test-server-ext", + "version": "0.1.0", + "label": "Test Server Extension", + "description": "A test server extension", + "category": "server", + "server": { + "command": "node", + "args": [ + "dist/index.js" + ], + "readyPattern": "Listening on", + "readyTimeout": 10000, + "healthCheck": "/api/health", + "singleton": true + } + } + } + }, + { + "name": "server-singleton-explicit-false", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":false}}", + "expected": { + "success": true, + "data": { + "name": "test-server-ext", + "version": "0.1.0", + "label": "Test Server Extension", + "description": "A test server extension", + "category": "server", + "server": { + "command": "node", + "args": [ + "dist/index.js" + ], + "readyPattern": "Listening on", + "readyTimeout": 10000, + "singleton": false + } + } + } + }, + { + "name": "server-defaults-materialize", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening\"}}", + "expected": { + "success": true, + "data": { + "name": "test-server-ext", + "version": "0.1.0", + "label": "Test Server Extension", + "description": "A test server extension", + "category": "server", + "server": { + "command": "node", + "args": [ + "dist/index.js" + ], + "readyPattern": "Listening", + "readyTimeout": 10000, + "singleton": true + } + } + } + }, + { + "name": "server-args-default-empty", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\"}}", + "expected": { + "success": true, + "data": { + "name": "test-server-ext", + "version": "0.1.0", + "label": "Test Server Extension", + "description": "A test server extension", + "category": "server", + "server": { + "command": "node", + "args": [], + "readyTimeout": 10000, + "singleton": true + } + } + } + }, + { + "name": "cli-args-default-empty", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"lazygit\"}}", + "expected": { + "success": true, + "data": { + "name": "test-cli-ext", + "version": "0.2.0", + "label": "Test CLI Extension", + "description": "A test CLI extension", + "category": "cli", + "cli": { + "command": "lazygit", + "args": [] + } + } + } + }, + { + "name": "cli-args-and-env", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"htop\",\"args\":[\"-d\",\"10\"],\"env\":{\"TERM\":\"xterm-256color\"}}}", + "expected": { + "success": true, + "data": { + "name": "test-cli-ext", + "version": "0.2.0", + "label": "Test CLI Extension", + "description": "A test CLI extension", + "category": "cli", + "cli": { + "command": "htop", + "args": [ + "-d", + "10" + ], + "env": { + "TERM": "xterm-256color" + } + } + } + } + }, + { + "name": "contentschema-all-three-field-types", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"contentSchema\":{\"name\":{\"type\":\"string\",\"label\":\"Name\",\"required\":true},\"count\":{\"type\":\"number\",\"label\":\"Count\",\"default\":5},\"verbose\":{\"type\":\"boolean\",\"label\":\"Verbose\",\"default\":false}}}", + "expected": { + "success": true, + "data": { + "name": "test-client-ext", + "version": "1.0.0", + "label": "Test Client Extension", + "description": "A test client extension", + "category": "client", + "contentSchema": { + "name": { + "type": "string", + "label": "Name", + "required": true + }, + "count": { + "type": "number", + "label": "Count", + "default": 5 + }, + "verbose": { + "type": "boolean", + "label": "Verbose", + "default": false + } + }, + "client": { + "entry": "./dist/index.html" + } + } + } + }, + { + "name": "contentschema-string-default", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"contentSchema\":{\"dir\":{\"type\":\"string\",\"label\":\"Directory\",\"default\":\"/tmp\"}}}", + "expected": { + "success": true, + "data": { + "name": "test-client-ext", + "version": "1.0.0", + "label": "Test Client Extension", + "description": "A test client extension", + "category": "client", + "contentSchema": { + "dir": { + "type": "string", + "label": "Directory", + "default": "/tmp" + } + }, + "client": { + "entry": "./dist/index.html" + } + } + } + }, + { + "name": "contentschema-empty-record", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"contentSchema\":{}}", + "expected": { + "success": true, + "data": { + "name": "test-client-ext", + "version": "1.0.0", + "label": "Test Client Extension", + "description": "A test client extension", + "category": "client", + "contentSchema": {}, + "client": { + "entry": "./dist/index.html" + } + } + } + }, + { + "name": "contentschema-number-float-default", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"contentSchema\":{\"ratio\":{\"type\":\"number\",\"label\":\"Ratio\",\"default\":1.5}}}", + "expected": { + "success": true, + "data": { + "name": "test-client-ext", + "version": "1.0.0", + "label": "Test Client Extension", + "description": "A test client extension", + "category": "client", + "contentSchema": { + "ratio": { + "type": "number", + "label": "Ratio", + "default": 1.5 + } + }, + "client": { + "entry": "./dist/index.html" + } + } + } + }, + { + "name": "empty-icon-string-valid", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true},\"icon\":\"\"}", + "expected": { + "success": true, + "data": { + "name": "test-server-ext", + "version": "0.1.0", + "label": "Test Server Extension", + "description": "A test server extension", + "category": "server", + "icon": "", + "server": { + "command": "node", + "args": [ + "dist/index.js" + ], + "readyPattern": "Listening on", + "readyTimeout": 10000, + "singleton": true + } + } + } + }, + { + "name": "empty-url-string-valid", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"url\":\"\"}", + "expected": { + "success": true, + "data": { + "name": "test-client-ext", + "version": "1.0.0", + "label": "Test Client Extension", + "description": "A test client extension", + "category": "client", + "url": "", + "client": { + "entry": "./dist/index.html" + } + } + } + }, + { + "name": "cli-empty-envvar-valid", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"htop\",\"envVar\":\"\"}}", + "expected": { + "success": true, + "data": { + "name": "test-cli-ext", + "version": "0.2.0", + "label": "Test CLI Extension", + "description": "A test CLI extension", + "category": "cli", + "cli": { + "command": "htop", + "args": [], + "envVar": "" + } + } + } + }, + { + "name": "server-empty-readypattern-valid", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"\",\"readyTimeout\":10000,\"singleton\":true}}", + "expected": { + "success": true, + "data": { + "name": "test-server-ext", + "version": "0.1.0", + "label": "Test Server Extension", + "description": "A test server extension", + "category": "server", + "server": { + "command": "node", + "args": [ + "dist/index.js" + ], + "readyPattern": "", + "readyTimeout": 10000, + "singleton": true + } + } + } + }, + { + "name": "server-empty-healthcheck-valid", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true,\"healthCheck\":\"\"}}", + "expected": { + "success": true, + "data": { + "name": "test-server-ext", + "version": "0.1.0", + "label": "Test Server Extension", + "description": "A test server extension", + "category": "server", + "server": { + "command": "node", + "args": [ + "dist/index.js" + ], + "readyPattern": "Listening on", + "readyTimeout": 10000, + "healthCheck": "", + "singleton": true + } + } + } + }, + { + "name": "picker-empty-shortcut-valid", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"picker\":{\"shortcut\":\"\"}}", + "expected": { + "success": true, + "data": { + "name": "test-client-ext", + "version": "1.0.0", + "label": "Test Client Extension", + "description": "A test client extension", + "category": "client", + "picker": { + "shortcut": "" + }, + "client": { + "entry": "./dist/index.html" + } + } + } + }, + { + "name": "name-whitespace-valid", + "rawText": "{\"name\":\" \",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"lazygit\"}}", + "expected": { + "success": true, + "data": { + "name": " ", + "version": "0.2.0", + "label": "Test CLI Extension", + "description": "A test CLI extension", + "category": "cli", + "cli": { + "command": "lazygit", + "args": [] + } + } + } + }, + { + "name": "args-empty-string-element-valid", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"htop\",\"args\":[\"\"]}}", + "expected": { + "success": true, + "data": { + "name": "test-cli-ext", + "version": "0.2.0", + "label": "Test CLI Extension", + "description": "A test CLI extension", + "category": "cli", + "cli": { + "command": "htop", + "args": [ + "" + ] + } + } + } + }, + { + "name": "terminalbehavior-both-fields", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"opencode\",\"terminalBehavior\":{\"preferredRenderer\":\"canvas\",\"scrollInputPolicy\":\"fallbackToCursorKeysWhenAltScreenMouseCapture\"}}}", + "expected": { + "success": true, + "data": { + "name": "test-cli-ext", + "version": "0.2.0", + "label": "Test CLI Extension", + "description": "A test CLI extension", + "category": "cli", + "cli": { + "command": "opencode", + "args": [], + "terminalBehavior": { + "preferredRenderer": "canvas", + "scrollInputPolicy": "fallbackToCursorKeysWhenAltScreenMouseCapture" + } + } + } + } + }, + { + "name": "terminalbehavior-scroll-input-policy-native", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"opencode\",\"terminalBehavior\":{\"scrollInputPolicy\":\"native\"}}}", + "expected": { + "success": true, + "data": { + "name": "test-cli-ext", + "version": "0.2.0", + "label": "Test CLI Extension", + "description": "A test CLI extension", + "category": "cli", + "cli": { + "command": "opencode", + "args": [], + "terminalBehavior": { + "scrollInputPolicy": "native" + } + } + } + } + }, + { + "name": "terminalbehavior-empty-object", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"opencode\",\"terminalBehavior\":{}}}", + "expected": { + "success": true, + "data": { + "name": "test-cli-ext", + "version": "0.2.0", + "label": "Test CLI Extension", + "description": "A test CLI extension", + "category": "cli", + "cli": { + "command": "opencode", + "args": [], + "terminalBehavior": {} + } + } + } + }, + { + "name": "server-readytimeout-max-safe-int", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":9007199254740991,\"singleton\":true}}", + "expected": { + "success": true, + "data": { + "name": "test-server-ext", + "version": "0.1.0", + "label": "Test Server Extension", + "description": "A test server extension", + "category": "server", + "server": { + "command": "node", + "args": [ + "dist/index.js" + ], + "readyPattern": "Listening on", + "readyTimeout": 9007199254740991, + "singleton": true + } + } + } + }, + { + "name": "duplicate-name-key-last-wins", + "rawText": "{ \"name\": \"first-name\", \"name\": \"second-name\", \"version\": \"1.0.0\", \"label\": \"L\", \"description\": \"D\", \"category\": \"cli\", \"cli\": { \"command\": \"x\" } }", + "expected": { + "success": true, + "data": { + "name": "second-name", + "version": "1.0.0", + "label": "L", + "description": "D", + "category": "cli", + "cli": { + "command": "x", + "args": [] + } + } + } + }, + { + "name": "invalid-json-text", + "rawText": "{ \"name\": \"x\", ", + "expected": { + "success": false, + "parseError": true + } + }, + { + "name": "missing-most-required-fields", + "rawText": "{\"name\":\"x\"}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "version" + ], + "message": "Invalid input: expected string, received undefined" + }, + { + "code": "invalid_type", + "path": [ + "label" + ], + "message": "Invalid input: expected string, received undefined" + }, + { + "code": "invalid_type", + "path": [ + "description" + ], + "message": "Invalid input: expected string, received undefined" + }, + { + "code": "invalid_value", + "path": [ + "category" + ], + "message": "Invalid option: expected one of \"client\"|\"server\"|\"cli\"" + } + ] + } + }, + { + "name": "empty-name", + "rawText": "{\"name\":\"\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true}}", + "expected": { + "success": false, + "issues": [ + { + "code": "too_small", + "path": [ + "name" + ], + "message": "Too small: expected string to have >=1 characters" + } + ] + } + }, + { + "name": "empty-version", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true}}", + "expected": { + "success": false, + "issues": [ + { + "code": "too_small", + "path": [ + "version" + ], + "message": "Too small: expected string to have >=1 characters" + } + ] + } + }, + { + "name": "empty-label", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true}}", + "expected": { + "success": false, + "issues": [ + { + "code": "too_small", + "path": [ + "label" + ], + "message": "Too small: expected string to have >=1 characters" + } + ] + } + }, + { + "name": "empty-description", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true}}", + "expected": { + "success": false, + "issues": [ + { + "code": "too_small", + "path": [ + "description" + ], + "message": "Too small: expected string to have >=1 characters" + } + ] + } + }, + { + "name": "name-wrong-type-number", + "rawText": "{\"name\":5,\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "name" + ], + "message": "Invalid input: expected string, received number" + } + ] + } + }, + { + "name": "version-missing", + "rawText": "{\"name\":\"test-server-ext\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "version" + ], + "message": "Invalid input: expected string, received undefined" + } + ] + } + }, + { + "name": "description-wrong-type-boolean", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":true,\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "description" + ], + "message": "Invalid input: expected string, received boolean" + } + ] + } + }, + { + "name": "category-invalid-string", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"weird\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_value", + "path": [ + "category" + ], + "message": "Invalid option: expected one of \"client\"|\"server\"|\"cli\"" + } + ] + } + }, + { + "name": "category-case-sensitive-CLI", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"CLI\",\"cli\":{\"command\":\"lazygit\"}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_value", + "path": [ + "category" + ], + "message": "Invalid option: expected one of \"client\"|\"server\"|\"cli\"" + } + ] + } + }, + { + "name": "category-wrong-type-number", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":5,\"cli\":{\"command\":\"lazygit\"}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_value", + "path": [ + "category" + ], + "message": "Invalid option: expected one of \"client\"|\"server\"|\"cli\"" + } + ] + } + }, + { + "name": "category-missing", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"cli\":{\"command\":\"lazygit\"}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_value", + "path": [ + "category" + ], + "message": "Invalid option: expected one of \"client\"|\"server\"|\"cli\"" + } + ] + } + }, + { + "name": "server-category-without-server-block", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\"}", + "expected": { + "success": false, + "issues": [ + { + "code": "custom", + "path": [], + "message": "category must have exactly its own config block (no others)" + } + ] + } + }, + { + "name": "client-category-without-client-block", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\"}", + "expected": { + "success": false, + "issues": [ + { + "code": "custom", + "path": [], + "message": "category must have exactly its own config block (no others)" + } + ] + } + }, + { + "name": "cli-category-without-cli-block", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\"}", + "expected": { + "success": false, + "issues": [ + { + "code": "custom", + "path": [], + "message": "category must have exactly its own config block (no others)" + } + ] + } + }, + { + "name": "server-category-with-extra-client-block", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true},\"client\":{\"entry\":\"./index.html\"}}", + "expected": { + "success": false, + "issues": [ + { + "code": "custom", + "path": [], + "message": "category must have exactly its own config block (no others)" + } + ] + } + }, + { + "name": "cli-category-with-all-three-blocks", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"lazygit\"},\"client\":{\"entry\":\"./index.html\"},\"server\":{\"command\":\"node\"}}", + "expected": { + "success": false, + "issues": [ + { + "code": "custom", + "path": [], + "message": "category must have exactly its own config block (no others)" + } + ] + } + }, + { + "name": "refine-gated-by-unrecognized-key", + "rawText": "{\"name\":\"x\",\"version\":\"1.0.0\",\"label\":\"L\",\"description\":\"D\",\"category\":\"cli\",\"bogusUnknownKey\":1}", + "expected": { + "success": false, + "issues": [ + { + "code": "unrecognized_keys", + "path": [], + "message": "Unrecognized key: \"bogusUnknownKey\"" + } + ] + } + }, + { + "name": "refine-gated-by-invalid-enum", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"weird\"}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_value", + "path": [ + "category" + ], + "message": "Invalid option: expected one of \"client\"|\"server\"|\"cli\"" + } + ] + } + }, + { + "name": "refine-not-gated-by-too-small", + "rawText": "{\"name\":\"\",\"version\":\"1.0.0\",\"label\":\"L\",\"description\":\"D\",\"category\":\"cli\"}", + "expected": { + "success": false, + "issues": [ + { + "code": "too_small", + "path": [ + "name" + ], + "message": "Too small: expected string to have >=1 characters" + }, + { + "code": "custom", + "path": [], + "message": "category must have exactly its own config block (no others)" + } + ] + } + }, + { + "name": "refine-passes-when-matching-block-has-only-check-failures", + "rawText": "{\"name\":\"x\",\"version\":\"1.0.0\",\"label\":\"L\",\"description\":\"D\",\"category\":\"server\",\"server\":{\"command\":\"\"}}", + "expected": { + "success": false, + "issues": [ + { + "code": "too_small", + "path": [ + "server", + "command" + ], + "message": "Too small: expected string to have >=1 characters" + } + ] + } + }, + { + "name": "both-refine-levels-fire-deeper-first", + "rawText": "{\"name\":\"x\",\"version\":\"1.0.0\",\"label\":\"L\",\"description\":\"D\",\"category\":\"server\",\"cli\":{\"command\":\"c\"},\"contentSchema\":{\"f\":{\"type\":\"number\",\"label\":\"L\",\"default\":\"s\"}}}", + "expected": { + "success": false, + "issues": [ + { + "code": "custom", + "path": [ + "contentSchema", + "f" + ], + "message": "default value must match the declared field type" + }, + { + "code": "custom", + "path": [], + "message": "category must have exactly its own config block (no others)" + } + ] + } + }, + { + "name": "unknown-top-level-key-typo", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true},\"descripton\":\"typo\"}", + "expected": { + "success": false, + "issues": [ + { + "code": "unrecognized_keys", + "path": [], + "message": "Unrecognized key: \"descripton\"" + } + ] + } + }, + { + "name": "unknown-top-level-keys-plural-order", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"lazygit\"},\"aa\":1,\"zz\":2}", + "expected": { + "success": false, + "issues": [ + { + "code": "unrecognized_keys", + "path": [], + "message": "Unrecognized keys: \"aa\", \"zz\"" + } + ] + } + }, + { + "name": "picker-unknown-key-typo", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"picker\":{\"shortcut\":\"C\",\"gropu\":\"tools\"}}", + "expected": { + "success": false, + "issues": [ + { + "code": "unrecognized_keys", + "path": [ + "picker" + ], + "message": "Unrecognized key: \"gropu\"" + } + ] + } + }, + { + "name": "server-config-unknown-key-typo", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true,\"commmand\":\"node\"}}", + "expected": { + "success": false, + "issues": [ + { + "code": "unrecognized_keys", + "path": [ + "server" + ], + "message": "Unrecognized key: \"commmand\"" + } + ] + } + }, + { + "name": "client-config-unknown-key", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./index.html\",\"entrypoint\":\"./other.html\"}}", + "expected": { + "success": false, + "issues": [ + { + "code": "unrecognized_keys", + "path": [ + "client" + ], + "message": "Unrecognized key: \"entrypoint\"" + } + ] + } + }, + { + "name": "cli-config-unknown-key-flags", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"htop\",\"flags\":[\"--color\"]}}", + "expected": { + "success": false, + "issues": [ + { + "code": "unrecognized_keys", + "path": [ + "cli" + ], + "message": "Unrecognized key: \"flags\"" + } + ] + } + }, + { + "name": "contentschema-field-unknown-key", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"contentSchema\":{\"name\":{\"type\":\"string\",\"label\":\"Name\",\"placeholder\":\"Enter name\"}}}", + "expected": { + "success": false, + "issues": [ + { + "code": "unrecognized_keys", + "path": [ + "contentSchema", + "name" + ], + "message": "Unrecognized key: \"placeholder\"" + } + ] + } + }, + { + "name": "terminalbehavior-unknown-key", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"x\",\"terminalBehavior\":{\"preferedRenderer\":\"canvas\"}}}", + "expected": { + "success": false, + "issues": [ + { + "code": "unrecognized_keys", + "path": [ + "cli", + "terminalBehavior" + ], + "message": "Unrecognized key: \"preferedRenderer\"" + } + ] + } + }, + { + "name": "server-command-empty", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true}}", + "expected": { + "success": false, + "issues": [ + { + "code": "too_small", + "path": [ + "server", + "command" + ], + "message": "Too small: expected string to have >=1 characters" + } + ] + } + }, + { + "name": "server-command-wrong-type", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":42,\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "server", + "command" + ], + "message": "Invalid input: expected string, received number" + } + ] + } + }, + { + "name": "server-command-missing", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"args\":[\"x\"]}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "server", + "command" + ], + "message": "Invalid input: expected string, received undefined" + } + ] + } + }, + { + "name": "server-readytimeout-negative", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":-1,\"singleton\":true}}", + "expected": { + "success": false, + "issues": [ + { + "code": "too_small", + "path": [ + "server", + "readyTimeout" + ], + "message": "Too small: expected number to be >0" + } + ] + } + }, + { + "name": "server-readytimeout-zero", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":0,\"singleton\":true}}", + "expected": { + "success": false, + "issues": [ + { + "code": "too_small", + "path": [ + "server", + "readyTimeout" + ], + "message": "Too small: expected number to be >0" + } + ] + } + }, + { + "name": "server-readytimeout-non-integer", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":1.5,\"singleton\":true}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "server", + "readyTimeout" + ], + "message": "Invalid input: expected int, received number" + } + ] + } + }, + { + "name": "server-readytimeout-negative-non-integer", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":-1.5,\"singleton\":true}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "server", + "readyTimeout" + ], + "message": "Invalid input: expected int, received number" + } + ] + } + }, + { + "name": "server-readytimeout-below-safe-int", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":-9007199254740992,\"singleton\":true}}", + "expected": { + "success": false, + "issues": [ + { + "code": "too_small", + "path": [ + "server", + "readyTimeout" + ], + "message": "Too small: expected int to be >=-9007199254740991" + }, + { + "code": "too_small", + "path": [ + "server", + "readyTimeout" + ], + "message": "Too small: expected number to be >0" + } + ] + } + }, + { + "name": "server-readytimeout-above-safe-int", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":9007199254740992,\"singleton\":true}}", + "expected": { + "success": false, + "issues": [ + { + "code": "too_big", + "path": [ + "server", + "readyTimeout" + ], + "message": "Too big: expected int to be <=9007199254740991" + } + ] + } + }, + { + "name": "server-readytimeout-text-beyond-2e53-rounds", + "rawText": "{ \"name\": \"x\", \"version\": \"1.0.0\", \"label\": \"L\", \"description\": \"D\", \"category\": \"server\", \"server\": { \"command\": \"node\", \"readyTimeout\": 9007199254740993 } }", + "expected": { + "success": false, + "issues": [ + { + "code": "too_big", + "path": [ + "server", + "readyTimeout" + ], + "message": "Too big: expected int to be <=9007199254740991" + } + ] + } + }, + { + "name": "server-readytimeout-wrong-type-string", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":\"10000\",\"singleton\":true}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "server", + "readyTimeout" + ], + "message": "Invalid input: expected number, received string" + } + ] + } + }, + { + "name": "server-args-non-string-element", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"a\",1,\"b\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "server", + "args", + 1 + ], + "message": "Invalid input: expected string, received number" + } + ] + } + }, + { + "name": "server-args-wrong-type", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":\"x\",\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "server", + "args" + ], + "message": "Invalid input: expected array, received string" + } + ] + } + }, + { + "name": "server-env-non-string-value", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":true,\"env\":{\"PORT\":3000}}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "server", + "env", + "PORT" + ], + "message": "Invalid input: expected string, received number" + } + ] + } + }, + { + "name": "server-singleton-wrong-type", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":10000,\"singleton\":\"yes\"}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "server", + "singleton" + ], + "message": "Invalid input: expected boolean, received string" + } + ] + } + }, + { + "name": "client-entry-empty", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"\"}}", + "expected": { + "success": false, + "issues": [ + { + "code": "too_small", + "path": [ + "client", + "entry" + ], + "message": "Too small: expected string to have >=1 characters" + } + ] + } + }, + { + "name": "client-entry-missing", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "client", + "entry" + ], + "message": "Invalid input: expected string, received undefined" + } + ] + } + }, + { + "name": "cli-command-empty", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"\"}}", + "expected": { + "success": false, + "issues": [ + { + "code": "too_small", + "path": [ + "cli", + "command" + ], + "message": "Too small: expected string to have >=1 characters" + } + ] + } + }, + { + "name": "cli-args-non-string-element", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"htop\",\"args\":[\"a\",1]}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "cli", + "args", + 1 + ], + "message": "Invalid input: expected string, received number" + } + ] + } + }, + { + "name": "cli-env-non-string-value", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"htop\",\"env\":{\"TERM\":5}}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "cli", + "env", + "TERM" + ], + "message": "Invalid input: expected string, received number" + } + ] + } + }, + { + "name": "cli-resumeargs-non-string-element", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"x\",\"resumeArgs\":[\"--resume\",42]}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "cli", + "resumeArgs", + 1 + ], + "message": "Invalid input: expected string, received number" + } + ] + } + }, + { + "name": "cli-supportspermissionmode-wrong-type", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"x\",\"supportsPermissionMode\":1}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "cli", + "supportsPermissionMode" + ], + "message": "Invalid input: expected boolean, received number" + } + ] + } + }, + { + "name": "cli-supportsmodel-wrong-type", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"x\",\"supportsModel\":\"true\"}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "cli", + "supportsModel" + ], + "message": "Invalid input: expected boolean, received string" + } + ] + } + }, + { + "name": "cli-supportssandbox-wrong-type", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"x\",\"supportsSandbox\":null}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "cli", + "supportsSandbox" + ], + "message": "Invalid input: expected boolean, received null" + } + ] + } + }, + { + "name": "cli-permissionmodevalues-non-string-value", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"x\",\"permissionModeValues\":{\"plan\":42}}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "cli", + "permissionModeValues", + "plan" + ], + "message": "Invalid input: expected string, received number" + } + ] + } + }, + { + "name": "cli-permissionmodeenvvar-wrong-type", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"x\",\"permissionModeEnvVar\":5}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "cli", + "permissionModeEnvVar" + ], + "message": "Invalid input: expected string, received number" + } + ] + } + }, + { + "name": "cli-block-wrong-type", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":\"htop\"}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "cli" + ], + "message": "Invalid input: expected object, received string" + } + ] + } + }, + { + "name": "cli-terminalbehavior-preferredrenderer-invalid-single-option-enum", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"x\",\"terminalBehavior\":{\"preferredRenderer\":\"dom\"}}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_value", + "path": [ + "cli", + "terminalBehavior", + "preferredRenderer" + ], + "message": "Invalid input: expected \"canvas\"" + } + ] + } + }, + { + "name": "cli-terminalbehavior-scrollinputpolicy-invalid-multi-option-enum", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"x\",\"terminalBehavior\":{\"scrollInputPolicy\":\"weird\"}}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_value", + "path": [ + "cli", + "terminalBehavior", + "scrollInputPolicy" + ], + "message": "Invalid option: expected one of \"native\"|\"fallbackToCursorKeysWhenAltScreenMouseCapture\"" + } + ] + } + }, + { + "name": "contentschema-invalid-field-type", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"contentSchema\":{\"bad\":{\"type\":\"object\",\"label\":\"Bad\"}}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_value", + "path": [ + "contentSchema", + "bad", + "type" + ], + "message": "Invalid option: expected one of \"string\"|\"number\"|\"boolean\"" + } + ] + } + }, + { + "name": "contentschema-field-missing-type", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"contentSchema\":{\"f\":{\"label\":\"L\"}}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_value", + "path": [ + "contentSchema", + "f", + "type" + ], + "message": "Invalid option: expected one of \"string\"|\"number\"|\"boolean\"" + } + ] + } + }, + { + "name": "contentschema-field-missing-label", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"contentSchema\":{\"f\":{\"type\":\"string\"}}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "contentSchema", + "f", + "label" + ], + "message": "Invalid input: expected string, received undefined" + } + ] + } + }, + { + "name": "contentschema-label-empty-string-valid", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"contentSchema\":{\"f\":{\"type\":\"string\",\"label\":\"\"}}}", + "expected": { + "success": true, + "data": { + "name": "test-client-ext", + "version": "1.0.0", + "label": "Test Client Extension", + "description": "A test client extension", + "category": "client", + "contentSchema": { + "f": { + "type": "string", + "label": "" + } + }, + "client": { + "entry": "./dist/index.html" + } + } + } + }, + { + "name": "contentschema-number-field-string-default-mismatch", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"contentSchema\":{\"count\":{\"type\":\"number\",\"label\":\"Count\",\"default\":\"not-a-number\"}}}", + "expected": { + "success": false, + "issues": [ + { + "code": "custom", + "path": [ + "contentSchema", + "count" + ], + "message": "default value must match the declared field type" + } + ] + } + }, + { + "name": "contentschema-boolean-field-number-default-mismatch", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"contentSchema\":{\"flag\":{\"type\":\"boolean\",\"label\":\"Flag\",\"default\":42}}}", + "expected": { + "success": false, + "issues": [ + { + "code": "custom", + "path": [ + "contentSchema", + "flag" + ], + "message": "default value must match the declared field type" + } + ] + } + }, + { + "name": "contentschema-string-field-boolean-default-mismatch", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"contentSchema\":{\"name\":{\"type\":\"string\",\"label\":\"Name\",\"default\":true}}}", + "expected": { + "success": false, + "issues": [ + { + "code": "custom", + "path": [ + "contentSchema", + "name" + ], + "message": "default value must match the declared field type" + } + ] + } + }, + { + "name": "contentschema-default-array-invalid-union", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"contentSchema\":{\"f\":{\"type\":\"string\",\"label\":\"L\",\"default\":[1]}}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_union", + "path": [ + "contentSchema", + "f", + "default" + ], + "message": "Invalid input" + } + ] + } + }, + { + "name": "contentschema-default-null-invalid-union", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"contentSchema\":{\"f\":{\"type\":\"string\",\"label\":\"L\",\"default\":null}}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_union", + "path": [ + "contentSchema", + "f", + "default" + ], + "message": "Invalid input" + } + ] + } + }, + { + "name": "contentschema-required-wrong-type", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"contentSchema\":{\"f\":{\"type\":\"string\",\"label\":\"L\",\"required\":\"yes\"}}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "contentSchema", + "f", + "required" + ], + "message": "Invalid input: expected boolean, received string" + } + ] + } + }, + { + "name": "field-refine-gated-by-aborting-member", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"contentSchema\":{\"f\":{\"type\":\"number\",\"label\":\"L\",\"default\":\"s\",\"required\":\"no\"}}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "contentSchema", + "f", + "required" + ], + "message": "Invalid input: expected boolean, received string" + } + ] + } + }, + { + "name": "contentschema-field-wrong-type", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"contentSchema\":{\"f\":\"not-an-object\"}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "contentSchema", + "f" + ], + "message": "Invalid input: expected object, received string" + } + ] + } + }, + { + "name": "contentschema-wrong-type", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"contentSchema\":\"x\"}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "contentSchema" + ], + "message": "Invalid input: expected record, received string" + } + ] + } + }, + { + "name": "picker-null-rejected", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"picker\":null}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "picker" + ], + "message": "Invalid input: expected object, received null" + } + ] + } + }, + { + "name": "icon-null-rejected", + "rawText": "{\"name\":\"test-client-ext\",\"version\":\"1.0.0\",\"label\":\"Test Client Extension\",\"description\":\"A test client extension\",\"category\":\"client\",\"client\":{\"entry\":\"./dist/index.html\"},\"icon\":null}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "icon" + ], + "message": "Invalid input: expected string, received null" + } + ] + } + }, + { + "name": "cli-env-null-rejected", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"command\":\"x\",\"env\":null}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "cli", + "env" + ], + "message": "Invalid input: expected record, received null" + } + ] + } + }, + { + "name": "server-null-rejected", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"server\",\"cli\":{\"command\":\"lazygit\"},\"server\":null}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "server" + ], + "message": "Invalid input: expected object, received null" + } + ] + } + }, + { + "name": "server-readytimeout-null-rejected", + "rawText": "{\"name\":\"test-server-ext\",\"version\":\"0.1.0\",\"label\":\"Test Server Extension\",\"description\":\"A test server extension\",\"category\":\"server\",\"server\":{\"command\":\"node\",\"args\":[\"dist/index.js\"],\"readyPattern\":\"Listening on\",\"readyTimeout\":null,\"singleton\":true}}", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [ + "server", + "readyTimeout" + ], + "message": "Invalid input: expected number, received null" + } + ] + } + }, + { + "name": "top-level-array", + "rawText": "[]", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [], + "message": "Invalid input: expected object, received array" + } + ] + } + }, + { + "name": "top-level-string", + "rawText": "\"hi\"", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [], + "message": "Invalid input: expected object, received string" + } + ] + } + }, + { + "name": "top-level-null", + "rawText": "null", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [], + "message": "Invalid input: expected object, received null" + } + ] + } + }, + { + "name": "top-level-number", + "rawText": "42", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [], + "message": "Invalid input: expected object, received number" + } + ] + } + }, + { + "name": "top-level-boolean", + "rawText": "true", + "expected": { + "success": false, + "issues": [ + { + "code": "invalid_type", + "path": [], + "message": "Invalid input: expected object, received boolean" + } + ] + } + }, + { + "name": "issue-order-definition-order-not-input-order", + "rawText": "{ \"version\": \"\", \"name\": \"\", \"category\": \"cli\", \"label\": \"\", \"description\": \"\", \"cli\": { \"command\": \"x\" } }", + "expected": { + "success": false, + "issues": [ + { + "code": "too_small", + "path": [ + "name" + ], + "message": "Too small: expected string to have >=1 characters" + }, + { + "code": "too_small", + "path": [ + "version" + ], + "message": "Too small: expected string to have >=1 characters" + }, + { + "code": "too_small", + "path": [ + "label" + ], + "message": "Too small: expected string to have >=1 characters" + }, + { + "code": "too_small", + "path": [ + "description" + ], + "message": "Too small: expected string to have >=1 characters" + } + ] + } + }, + { + "name": "unrecognized-keys-issue-position-after-field-issues", + "rawText": "{\"aa\":1,\"name\":\"\",\"version\":5,\"label\":\"L\",\"description\":\"D\",\"category\":\"weird\",\"zz\":2,\"cli\":{\"command\":\"x\"}}", + "expected": { + "success": false, + "issues": [ + { + "code": "too_small", + "path": [ + "name" + ], + "message": "Too small: expected string to have >=1 characters" + }, + { + "code": "invalid_type", + "path": [ + "version" + ], + "message": "Invalid input: expected string, received number" + }, + { + "code": "invalid_value", + "path": [ + "category" + ], + "message": "Invalid option: expected one of \"client\"|\"server\"|\"cli\"" + }, + { + "code": "unrecognized_keys", + "path": [], + "message": "Unrecognized keys: \"aa\", \"zz\"" + } + ] + } + }, + { + "name": "nested-block-definition-order-pin", + "rawText": "{\"name\":\"test-cli-ext\",\"version\":\"0.2.0\",\"label\":\"Test CLI Extension\",\"description\":\"A test CLI extension\",\"category\":\"cli\",\"cli\":{\"env\":{\"X\":1},\"command\":\"\",\"supportsModel\":\"x\",\"bogus\":2}}", + "expected": { + "success": false, + "issues": [ + { + "code": "too_small", + "path": [ + "cli", + "command" + ], + "message": "Too small: expected string to have >=1 characters" + }, + { + "code": "invalid_type", + "path": [ + "cli", + "env", + "X" + ], + "message": "Invalid input: expected string, received number" + }, + { + "code": "invalid_type", + "path": [ + "cli", + "supportsModel" + ], + "message": "Invalid input: expected boolean, received string" + }, + { + "code": "unrecognized_keys", + "path": [ + "cli" + ], + "message": "Unrecognized key: \"bogus\"" + } + ] + } + }, + { + "name": "proto-in-env-skipped-even-with-invalid-value", + "rawText": "{ \"name\": \"x\", \"version\": \"1.0.0\", \"label\": \"L\", \"description\": \"D\", \"category\": \"cli\", \"cli\": { \"command\": \"c\", \"env\": { \"__proto__\": 5, \"x\": \"y\" } } }", + "expected": { + "success": true, + "data": { + "name": "x", + "version": "1.0.0", + "label": "L", + "description": "D", + "category": "cli", + "cli": { + "command": "c", + "args": [], + "env": { + "x": "y" + } + } + } + } + }, + { + "name": "proto-in-contentschema-field-never-validated", + "rawText": "{ \"name\": \"x\", \"version\": \"1.0.0\", \"label\": \"L\", \"description\": \"D\", \"category\": \"client\", \"client\": { \"entry\": \"e\" }, \"contentSchema\": { \"__proto__\": { \"type\": \"bad\" }, \"a\": { \"type\": \"string\", \"label\": \"L\" } } }", + "expected": { + "success": true, + "data": { + "name": "x", + "version": "1.0.0", + "label": "L", + "description": "D", + "category": "client", + "contentSchema": { + "a": { + "type": "string", + "label": "L" + } + }, + "client": { + "entry": "e" + } + } + } + }, + { + "name": "proto-top-level-is-unrecognized-key", + "rawText": "{ \"name\": \"x\", \"version\": \"1.0.0\", \"label\": \"L\", \"description\": \"D\", \"category\": \"cli\", \"cli\": { \"command\": \"c\" }, \"__proto__\": 1 }", + "expected": { + "success": false, + "issues": [ + { + "code": "unrecognized_keys", + "path": [], + "message": "Unrecognized key: \"__proto__\"" + } + ] + } + }, + { + "name": "unrecognized-numeric-keys-list-in-numeric-order", + "rawText": "{ \"name\": \"x\", \"version\": \"1.0.0\", \"label\": \"L\", \"description\": \"D\", \"category\": \"cli\", \"cli\": { \"command\": \"c\" }, \"10\": 1, \"2\": 1 }", + "expected": { + "success": false, + "issues": [ + { + "code": "unrecognized_keys", + "path": [], + "message": "Unrecognized keys: \"2\", \"10\"" + } + ] + } + }, + { + "name": "huge-int-default-rounds-like-js", + "rawText": "{ \"name\": \"x\", \"version\": \"1.0.0\", \"label\": \"L\", \"description\": \"D\", \"category\": \"client\", \"client\": { \"entry\": \"e\" }, \"contentSchema\": { \"f\": { \"type\": \"number\", \"label\": \"L\", \"default\": 12345678901234567890 } } }", + "expected": { + "success": true, + "data": { + "name": "x", + "version": "1.0.0", + "label": "L", + "description": "D", + "category": "client", + "contentSchema": { + "f": { + "type": "number", + "label": "L", + "default": 12345678901234567000 + } + }, + "client": { + "entry": "e" + } + } + } + }, + { + "name": "integral-float-text-default-2e53", + "rawText": "{ \"name\": \"x\", \"version\": \"1.0.0\", \"label\": \"L\", \"description\": \"D\", \"category\": \"client\", \"client\": { \"entry\": \"e\" }, \"contentSchema\": { \"f\": { \"type\": \"number\", \"label\": \"L\", \"default\": 9007199254740992.0 } } }", + "expected": { + "success": true, + "data": { + "name": "x", + "version": "1.0.0", + "label": "L", + "description": "D", + "category": "client", + "contentSchema": { + "f": { + "type": "number", + "label": "L", + "default": 9007199254740992 + } + }, + "client": { + "entry": "e" + } + } + } + }, + { + "name": "bundled-amplifier", + "rawText": "{\n \"name\": \"amplifier\",\n \"version\": \"1.0.0\",\n \"label\": \"Amplifier\",\n \"description\": \"Microsoft's Amplifier CLI agent\",\n \"category\": \"cli\",\n \"cli\": {\n \"command\": \"amplifier\",\n \"envVar\": \"AMPLIFIER_CMD\",\n \"resumeArgs\": [\"session\", \"resume\", \"--full-history\", \"{{sessionId}}\"],\n \"env\": { \"PROMPT_TOOLKIT_NO_CPR\": \"1\" }\n },\n \"picker\": {\n \"shortcut\": \"A\",\n \"group\": \"agents\"\n }\n}\n", + "expected": { + "success": true, + "data": { + "name": "amplifier", + "version": "1.0.0", + "label": "Amplifier", + "description": "Microsoft's Amplifier CLI agent", + "category": "cli", + "picker": { + "shortcut": "A", + "group": "agents" + }, + "cli": { + "command": "amplifier", + "args": [], + "env": { + "PROMPT_TOOLKIT_NO_CPR": "1" + }, + "envVar": "AMPLIFIER_CMD", + "resumeArgs": [ + "session", + "resume", + "--full-history", + "{{sessionId}}" + ] + } + } + } + }, + { + "name": "bundled-claude-code", + "rawText": "{\n \"name\": \"claude\",\n \"version\": \"1.0.0\",\n \"label\": \"Claude CLI\",\n \"description\": \"Anthropic's Claude Code CLI agent\",\n \"category\": \"cli\",\n \"cli\": {\n \"command\": \"claude\",\n \"envVar\": \"CLAUDE_CMD\",\n \"resumeArgs\": [\"--resume\", \"{{sessionId}}\"],\n \"createSessionArgs\": [\"--session-id\", \"{{sessionId}}\"],\n \"permissionModeArgs\": [\"--permission-mode\", \"{{permissionMode}}\"],\n \"supportsPermissionMode\": true\n },\n \"picker\": {\n \"shortcut\": \"L\",\n \"group\": \"agents\"\n }\n}\n", + "expected": { + "success": true, + "data": { + "name": "claude", + "version": "1.0.0", + "label": "Claude CLI", + "description": "Anthropic's Claude Code CLI agent", + "category": "cli", + "picker": { + "shortcut": "L", + "group": "agents" + }, + "cli": { + "command": "claude", + "args": [], + "envVar": "CLAUDE_CMD", + "resumeArgs": [ + "--resume", + "{{sessionId}}" + ], + "createSessionArgs": [ + "--session-id", + "{{sessionId}}" + ], + "permissionModeArgs": [ + "--permission-mode", + "{{permissionMode}}" + ], + "supportsPermissionMode": true + } + } + } + }, + { + "name": "bundled-codex-cli", + "rawText": "{\n \"name\": \"codex\",\n \"version\": \"1.0.0\",\n \"label\": \"Codex CLI\",\n \"description\": \"OpenAI's Codex CLI agent\",\n \"category\": \"cli\",\n \"cli\": {\n \"command\": \"codex\",\n \"envVar\": \"CODEX_CMD\",\n \"resumeArgs\": [\"resume\", \"{{sessionId}}\"],\n \"modelArgs\": [\"--model\", \"{{model}}\"],\n \"sandboxArgs\": [\"--sandbox\", \"{{sandbox}}\"],\n \"supportsModel\": true,\n \"supportsSandbox\": true\n },\n \"picker\": {\n \"shortcut\": \"X\",\n \"group\": \"agents\"\n }\n}\n", + "expected": { + "success": true, + "data": { + "name": "codex", + "version": "1.0.0", + "label": "Codex CLI", + "description": "OpenAI's Codex CLI agent", + "category": "cli", + "picker": { + "shortcut": "X", + "group": "agents" + }, + "cli": { + "command": "codex", + "args": [], + "envVar": "CODEX_CMD", + "resumeArgs": [ + "resume", + "{{sessionId}}" + ], + "modelArgs": [ + "--model", + "{{model}}" + ], + "sandboxArgs": [ + "--sandbox", + "{{sandbox}}" + ], + "supportsModel": true, + "supportsSandbox": true + } + } + } + }, + { + "name": "bundled-gemini", + "rawText": "{\n \"name\": \"gemini\",\n \"version\": \"1.0.0\",\n \"label\": \"Gemini\",\n \"description\": \"Google's Gemini CLI agent\",\n \"category\": \"cli\",\n \"cli\": {\n \"command\": \"gemini\",\n \"envVar\": \"GEMINI_CMD\"\n },\n \"picker\": {\n \"group\": \"agents\"\n }\n}\n", + "expected": { + "success": true, + "data": { + "name": "gemini", + "version": "1.0.0", + "label": "Gemini", + "description": "Google's Gemini CLI agent", + "category": "cli", + "picker": { + "group": "agents" + }, + "cli": { + "command": "gemini", + "args": [], + "envVar": "GEMINI_CMD" + } + } + } + }, + { + "name": "bundled-kimi", + "rawText": "{\n \"name\": \"kimi\",\n \"version\": \"1.0.0\",\n \"label\": \"Kimi\",\n \"description\": \"Kimi CLI agent\",\n \"category\": \"cli\",\n \"cli\": {\n \"command\": \"kimi\",\n \"envVar\": \"KIMI_CMD\"\n },\n \"picker\": {\n \"group\": \"agents\"\n }\n}\n", + "expected": { + "success": true, + "data": { + "name": "kimi", + "version": "1.0.0", + "label": "Kimi", + "description": "Kimi CLI agent", + "category": "cli", + "picker": { + "group": "agents" + }, + "cli": { + "command": "kimi", + "args": [], + "envVar": "KIMI_CMD" + } + } + } + }, + { + "name": "bundled-opencode", + "rawText": "{\n \"name\": \"opencode\",\n \"version\": \"1.0.0\",\n \"label\": \"OpenCode\",\n \"description\": \"OpenCode CLI agent\",\n \"category\": \"cli\",\n \"cli\": {\n \"command\": \"opencode\",\n \"envVar\": \"OPENCODE_CMD\",\n \"resumeArgs\": [\"--session\", \"{{sessionId}}\"],\n \"modelArgs\": [\"--model\", \"{{model}}\"],\n \"supportsModel\": true,\n \"terminalBehavior\": {\n \"preferredRenderer\": \"canvas\",\n \"scrollInputPolicy\": \"native\"\n }\n },\n \"picker\": {\n \"group\": \"agents\"\n }\n}\n", + "expected": { + "success": true, + "data": { + "name": "opencode", + "version": "1.0.0", + "label": "OpenCode", + "description": "OpenCode CLI agent", + "category": "cli", + "picker": { + "group": "agents" + }, + "cli": { + "command": "opencode", + "args": [], + "envVar": "OPENCODE_CMD", + "resumeArgs": [ + "--session", + "{{sessionId}}" + ], + "modelArgs": [ + "--model", + "{{model}}" + ], + "supportsModel": true, + "terminalBehavior": { + "preferredRenderer": "canvas", + "scrollInputPolicy": "native" + } + } + } + } + } + ] +} diff --git a/crates/freshell-extensions/src/issue.rs b/crates/freshell-extensions/src/issue.rs new file mode 100644 index 000000000..c754d9864 --- /dev/null +++ b/crates/freshell-extensions/src/issue.rs @@ -0,0 +1,133 @@ +//! The issue model for manifest validation — zod-parity types. +//! +//! Issues are zod's flattened `{code, path, message}` triples with byte-exact +//! zod-4.3.6 message text (the legacy `.format()` log nesting is intentionally +//! not reproduced; content parity, shape flattened — see `docs/plans/df1/EXT-01.md` DC-3). + +// ────────────────────────────────────────────────────────────── +// Issue model (zod-parity) +// ────────────────────────────────────────────────────────────── + +/// One path segment — object key or array index (the zod `PropertyKey` subset +/// reachable from JSON). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PathSeg { + Key(String), + Index(u32), +} + +impl serde::Serialize for PathSeg { + fn serialize(&self, s: S) -> Result { + match self { + PathSeg::Key(k) => k.serialize(s), + PathSeg::Index(i) => i.serialize(s), + } + } +} + +impl std::fmt::Display for PathSeg { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PathSeg::Key(k) => write!(f, "{k}"), + PathSeg::Index(i) => write!(f, "{i}"), + } + } +} + +/// zod 4.3.6 issue codes reachable from this schema. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IssueCode { + InvalidType, + TooSmall, + TooBig, + InvalidValue, + UnrecognizedKeys, + InvalidUnion, + Custom, +} + +impl IssueCode { + pub fn as_str(self) -> &'static str { + match self { + IssueCode::InvalidType => "invalid_type", + IssueCode::TooSmall => "too_small", + IssueCode::TooBig => "too_big", + IssueCode::InvalidValue => "invalid_value", + IssueCode::UnrecognizedKeys => "unrecognized_keys", + IssueCode::InvalidUnion => "invalid_union", + IssueCode::Custom => "custom", + } + } + + /// DC-4.2: codes whose presence in a refined schema's subtree suppresses + /// that refine. Aborting: the base-parse failures (invalid_type, + /// invalid_value, invalid_union, unrecognized_keys). NON-aborting: the + /// accumulate-only check codes (too_small, too_big) AND custom (a + /// refine's own output never gates other refines — pinned by the + /// `both-refine-levels-fire-deeper-first` oracle row). + pub(crate) fn is_aborting(self) -> bool { + !matches!( + self, + IssueCode::TooSmall | IssueCode::TooBig | IssueCode::Custom + ) + } +} + +impl serde::Serialize for IssueCode { + fn serialize(&self, s: S) -> Result { + s.serialize_str(self.as_str()) + } +} + +/// A flattened zod issue: `(code, path, message)` where `message` byte- +/// matches zod 4.3.6's text. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct ManifestIssue { + pub code: IssueCode, + pub path: Vec, + pub message: String, +} + +impl std::fmt::Display for ManifestIssue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[{}", self.code.as_str())?; + if !self.path.is_empty() { + write!(f, " ")?; + for (i, seg) in self.path.iter().enumerate() { + if i > 0 { + write!(f, ".")?; + } + write!(f, "{seg}")?; + } + } + write!(f, "] {}", self.message) + } +} + +/// The two rejection classes, mirroring `extension-manager.ts`'s two scan log +/// lines: `InvalidJson` for 'invalid JSON in manifest' (the file text is not +/// JSON at all — legacy logs the `JSON.parse` error), `Invalid` for +/// 'invalid manifest' (parsed JSON failed the schema — carries the zod-parity +/// issue list legacy passes through `result.error.format()`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ManifestError { + InvalidJson(String), + Invalid(Vec), +} + +impl std::fmt::Display for ManifestError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ManifestError::InvalidJson(e) => write!(f, "invalid JSON in manifest: {e}"), + ManifestError::Invalid(issues) => { + write!(f, "invalid manifest ({} issue(s)):", issues.len())?; + for i in issues { + write!(f, " {i};")?; + } + Ok(()) + } + } + } +} + +impl std::error::Error for ManifestError {} diff --git a/crates/freshell-extensions/src/lib.rs b/crates/freshell-extensions/src/lib.rs new file mode 100644 index 000000000..17da56c0b --- /dev/null +++ b/crates/freshell-extensions/src/lib.rs @@ -0,0 +1,42 @@ +//! Extension manifest validation for the freshell Rust port (df1 EXT-01). +//! +//! Ports the legacy strict manifest schema — `server/extension-manifest.ts` +//! (zod 4.3.6, the package-lock pin) — with behavior-for-behavior parity: +//! +//! * strict objects reject unknown keys at every level (`unrecognized_keys`) +//! * category↔config-block coupling refine (exactly one `client`/`server`/ +//! `cli` block, matching `category`), including zod-4's refine-gating abort +//! rule (aborting issue codes suppress refines; check codes don't) and its +//! best-effort block presence semantics +//! * defaults materialize in validated output: `server.args=[]`, +//! `server.readyTimeout=10000`, `server.singleton=true`, `cli.args=[]` +//! * `readyTimeout` is a JS-safe-int (`±2^53-1`) positive millisecond value +//! * bare-string fields (`icon`, `url`, `envVar`, `readyPattern`, …) ACCEPT +//! empty strings; only `name`/`version`/`label`/`description`/ +//! `client.entry`/`server.command`/`cli.command` enforce min(1) +//! * `.optional()` means ABSENT-or-`T` — a literal JSON `null` is rejected +//! * content-schema field `default` is `string | number | boolean` and, when +//! present, must match the declared field `type` (JS `typeof` semantics) +//! * issues carry zod-4's exact (code, path, message) triples, in zod's +//! emission order (schema-definition order; `unrecognized_keys` last per +//! object; refines after their object's base issues) +//! +//! Behavior is pinned by a differential oracle: +//! `fixtures/manifest-oracle.json` (124 cases) generated from the UNMODIFIED +//! legacy schema by `port/contract/generate-manifest-oracle.ts`; iterated by +//! `tests/oracle.rs`. Never hand-edit the fixture to match this crate — +//! regenerate it and fix the crate instead. +//! +//! Locale note: JSON text in, typed manifest out. No I/O, no clocks, no +//! randomness — hermetic by construction. + +mod issue; +mod manifest; +mod validate; + +pub use issue::{IssueCode, ManifestError, ManifestIssue, PathSeg}; +pub use manifest::{ + Category, CliConfig, ClientConfig, ContentSchemaField, DefaultValue, ExtensionManifest, + FieldType, PickerConfig, PreferredRenderer, ScrollInputPolicy, ServerConfig, TerminalBehavior, +}; +pub use validate::{parse_manifest, validate_manifest}; diff --git a/crates/freshell-extensions/src/manifest.rs b/crates/freshell-extensions/src/manifest.rs new file mode 100644 index 000000000..f87d6d2bf --- /dev/null +++ b/crates/freshell-extensions/src/manifest.rs @@ -0,0 +1,465 @@ +//! The typed manifest model (`ExtensionManifest` and its blocks), mirroring +//! `z.infer` from `server/extension-manifest.ts` +//! — with zod defaults MATERIALIZED (see field docs). +//! +//! Wire (de)serialization: `Serialize` reproduces zod's output-object shape +//! exactly (camelCase keys, absent optionals elided, materialized defaults +//! always present) — this is what the registry echoes to clients. No +//! `Deserialize` impls exist ON PURPOSE: the ONLY way to obtain this model is +//! `validate_manifest`/`parse_manifest`, so the lenient-vs-strict split that +//! bit the `freshell-server` subset port can't reappear here. + +use indexmap::IndexMap; +use serde::{Serialize, Serializer}; +use serde_json::{Map, Number, Value}; + +/// `category: z.enum(['client', 'server', 'cli'])`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Category { + Client, + Server, + Cli, +} + +impl Category { + pub fn as_str(self) -> &'static str { + match self { + Category::Client => "client", + Category::Server => "server", + Category::Cli => "cli", + } + } +} + +impl Serialize for Category { + fn serialize(&self, s: S) -> Result { + s.serialize_str(self.as_str()) + } +} + +/// `type: z.enum(['string', 'number', 'boolean'])` on a content-schema field. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FieldType { + String, + Number, + Boolean, +} + +impl FieldType { + pub fn as_str(self) -> &'static str { + match self { + FieldType::String => "string", + FieldType::Number => "number", + FieldType::Boolean => "boolean", + } + } +} + +impl Serialize for FieldType { + fn serialize(&self, s: S) -> Result { + s.serialize_str(self.as_str()) + } +} + +/// `default: z.union([z.string(), z.number(), z.boolean()])`. +/// +/// Numbers integrate to a canonical [`serde_json::Number`]: integral values in +/// `±(2^53-1)` are stored as integers so re-serialization matches +/// `JSON.stringify` (which never prints a trailing `.0`); non-integral values +/// stay f64. +#[derive(Debug, Clone, PartialEq)] +pub enum DefaultValue { + String(String), + Number(Number), + Boolean(bool), +} + +impl DefaultValue { + /// Build a number default from a parsed JSON number, matching the DOUBLE + /// `JSON.parse` produces bit-for-bit: everything goes through f64 first + /// (so integer text beyond 2^53 rounds like JS instead of staying u64- + /// exact), then integral values in `±(2^53-1)` are stored as integers so + /// re-serialization matches `JSON.stringify` (which never prints a + /// trailing `.0`). Larger or non-integral values stay f64 — the stored + /// double is identical to JS's; only exponent-notation text cosmetics + /// differ at extremes (recorded divergence). + pub(crate) fn number(n: &Number) -> Self { + let f = n.as_f64().expect("serde_json::Number is always f64-able"); + if f.fract() == 0.0 && f.abs() <= 9007199254740991.0 { + return DefaultValue::Number(Number::from(f as i64)); + } + DefaultValue::Number(Number::from_f64(f).expect("JSON numbers are finite")) + } + + /// The JS `typeof` of this value — the operand of the content-schema + /// field-type refine (`typeof field.default === field.type`). + pub(crate) fn js_typeof(&self) -> &'static str { + match self { + DefaultValue::String(_) => "string", + DefaultValue::Number(_) => "number", + DefaultValue::Boolean(_) => "boolean", + } + } +} + +impl Serialize for DefaultValue { + fn serialize(&self, s: S) -> Result { + match self { + DefaultValue::String(v) => v.serialize(s), + DefaultValue::Number(v) => v.serialize(s), + DefaultValue::Boolean(v) => v.serialize(s), + } + } +} + +/// `ContentSchemaFieldSchema` — a dynamic field descriptor for extension +/// props (`extension-manifest.ts:14-25`). +#[derive(Debug, Clone, PartialEq)] +pub struct ContentSchemaField { + pub field_type: FieldType, + pub label: String, + pub required: Option, + pub default: Option, +} + +impl Serialize for ContentSchemaField { + fn serialize(&self, s: S) -> Result { + let mut m = Map::new(); + m.insert( + "type".into(), + Value::String(self.field_type.as_str().into()), + ); + m.insert("label".into(), Value::String(self.label.clone())); + if let Some(v) = self.required { + m.insert("required".into(), Value::Bool(v)); + } + if let Some(v) = &self.default { + m.insert( + "default".into(), + serde_json::to_value(v).unwrap_or(Value::Null), + ); + } + Value::Object(m).serialize(s) + } +} + +/// `preferredRenderer: z.enum(['canvas'])` — single-option enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PreferredRenderer { + Canvas, +} + +impl Serialize for PreferredRenderer { + fn serialize(&self, s: S) -> Result { + s.serialize_str("canvas") + } +} + +/// `scrollInputPolicy: z.enum(['native', +/// 'fallbackToCursorKeysWhenAltScreenMouseCapture'])`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScrollInputPolicy { + Native, + FallbackToCursorKeysWhenAltScreenMouseCapture, +} + +impl Serialize for ScrollInputPolicy { + fn serialize(&self, s: S) -> Result { + s.serialize_str(match self { + ScrollInputPolicy::Native => "native", + ScrollInputPolicy::FallbackToCursorKeysWhenAltScreenMouseCapture => { + "fallbackToCursorKeysWhenAltScreenMouseCapture" + } + }) + } +} + +/// `TerminalBehaviorConfigSchema` (`extension-manifest.ts:45-48`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TerminalBehavior { + pub preferred_renderer: Option, + pub scroll_input_policy: Option, +} + +impl Serialize for TerminalBehavior { + fn serialize(&self, s: S) -> Result { + let mut m = Map::new(); + if let Some(v) = &self.preferred_renderer { + m.insert( + "preferredRenderer".into(), + serde_json::to_value(v).unwrap_or(Value::Null), + ); + } + if let Some(v) = &self.scroll_input_policy { + m.insert( + "scrollInputPolicy".into(), + serde_json::to_value(v).unwrap_or(Value::Null), + ); + } + Value::Object(m).serialize(s) + } +} + +/// `ClientConfigSchema` (`extension-manifest.ts:31-33`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClientConfig { + pub entry: String, +} + +impl Serialize for ClientConfig { + fn serialize(&self, s: S) -> Result { + let mut m = Map::new(); + m.insert("entry".into(), Value::String(self.entry.clone())); + Value::Object(m).serialize(s) + } +} + +/// `ServerConfigSchema` (`extension-manifest.ts:35-43`) with zod defaults +/// materialized: `args` and `ready_timeout` and `singleton` are always +/// concrete here (defaults `[]`, `10000`, `true`). +#[derive(Debug, Clone, PartialEq)] +pub struct ServerConfig { + pub command: String, + /// Default-materialized (`[]` when absent in the manifest). + pub args: Vec, + pub env: Option>, + pub ready_pattern: Option, + /// Default-materialized (`10000` when absent). Validated as a JS-safe-int + /// positive millisecond value: `1..=9007199254740991`. + pub ready_timeout: u64, + pub health_check: Option, + /// Default-materialized (`true` when absent). + pub singleton: bool, +} + +impl Serialize for ServerConfig { + fn serialize(&self, s: S) -> Result { + let mut m = Map::new(); + m.insert("command".into(), Value::String(self.command.clone())); + m.insert( + "args".into(), + serde_json::to_value(&self.args).unwrap_or(Value::Null), + ); + if let Some(v) = &self.env { + m.insert("env".into(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(v) = &self.ready_pattern { + m.insert("readyPattern".into(), Value::String(v.clone())); + } + m.insert( + "readyTimeout".into(), + Value::Number(Number::from(self.ready_timeout)), + ); + if let Some(v) = &self.health_check { + m.insert("healthCheck".into(), Value::String(v.clone())); + } + m.insert("singleton".into(), Value::Bool(self.singleton)); + Value::Object(m).serialize(s) + } +} + +/// `CliConfigSchema` (`extension-manifest.ts:50-66`) — the full launch/ +/// capability surface: command override, args/env, create/resume identity +/// (`createSessionArgs`/`resumeArgs` `{{sessionId}}` templates), models +/// (`modelArgs`/`supportsModel`), sandbox (`sandboxArgs`/`supportsSandbox`), +/// permissions (`permissionModeArgs`/`permissionModeEnvVar`/ +/// `permissionModeValues`/`supportsPermissionMode`), and terminal behavior. +#[derive(Debug, Clone, PartialEq)] +pub struct CliConfig { + pub command: String, + /// Default-materialized (`[]` when absent in the manifest). + pub args: Vec, + pub env: Option>, + /// Env var that overrides `command` (e.g. `CLAUDE_CMD`). + pub env_var: Option, + /// `{{sessionId}}` template for resuming a session. + pub resume_args: Option>, + /// `{{sessionId}}` template for fresh session identity. + pub create_session_args: Option>, + /// `{{model}}` template. + pub model_args: Option>, + /// `{{sandbox}}` template. + pub sandbox_args: Option>, + /// `{{permissionMode}}` template. + pub permission_mode_args: Option>, + pub permission_mode_env_var: Option, + pub permission_mode_values: Option>, + pub supports_permission_mode: Option, + pub supports_model: Option, + pub supports_sandbox: Option, + pub terminal_behavior: Option, +} + +impl Serialize for CliConfig { + fn serialize(&self, s: S) -> Result { + let mut m = Map::new(); + m.insert("command".into(), Value::String(self.command.clone())); + m.insert( + "args".into(), + serde_json::to_value(&self.args).unwrap_or(Value::Null), + ); + if let Some(v) = &self.env { + m.insert("env".into(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(v) = &self.env_var { + m.insert("envVar".into(), Value::String(v.clone())); + } + if let Some(v) = &self.resume_args { + m.insert( + "resumeArgs".into(), + serde_json::to_value(v).unwrap_or(Value::Null), + ); + } + if let Some(v) = &self.create_session_args { + m.insert( + "createSessionArgs".into(), + serde_json::to_value(v).unwrap_or(Value::Null), + ); + } + if let Some(v) = &self.model_args { + m.insert( + "modelArgs".into(), + serde_json::to_value(v).unwrap_or(Value::Null), + ); + } + if let Some(v) = &self.sandbox_args { + m.insert( + "sandboxArgs".into(), + serde_json::to_value(v).unwrap_or(Value::Null), + ); + } + if let Some(v) = &self.permission_mode_args { + m.insert( + "permissionModeArgs".into(), + serde_json::to_value(v).unwrap_or(Value::Null), + ); + } + if let Some(v) = &self.permission_mode_env_var { + m.insert("permissionModeEnvVar".into(), Value::String(v.clone())); + } + if let Some(v) = &self.permission_mode_values { + m.insert( + "permissionModeValues".into(), + serde_json::to_value(v).unwrap_or(Value::Null), + ); + } + if let Some(v) = self.supports_permission_mode { + m.insert("supportsPermissionMode".into(), Value::Bool(v)); + } + if let Some(v) = self.supports_model { + m.insert("supportsModel".into(), Value::Bool(v)); + } + if let Some(v) = self.supports_sandbox { + m.insert("supportsSandbox".into(), Value::Bool(v)); + } + if let Some(v) = &self.terminal_behavior { + m.insert( + "terminalBehavior".into(), + serde_json::to_value(v).unwrap_or(Value::Null), + ); + } + Value::Object(m).serialize(s) + } +} + +/// `PickerConfigSchema` (`extension-manifest.ts:72-75`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PickerConfig { + pub shortcut: Option, + pub group: Option, +} + +impl Serialize for PickerConfig { + fn serialize(&self, s: S) -> Result { + let mut m = Map::new(); + if let Some(v) = &self.shortcut { + m.insert("shortcut".into(), Value::String(v.clone())); + } + if let Some(v) = &self.group { + m.insert("group".into(), Value::String(v.clone())); + } + Value::Object(m).serialize(s) + } +} + +/// The top-level manifest (`extension-manifest.ts:81-103`). Exactly one of +/// `client`/`server`/`cli` is `Some`, always the one matching `category` +/// (enforced by the validator's refine). +#[derive(Debug, Clone, PartialEq)] +pub struct ExtensionManifest { + pub name: String, + pub version: String, + pub label: String, + pub description: String, + pub category: Category, + pub icon: Option, + pub url: Option, + /// Insertion-ordered (manifest text order) — the client renders + /// content-schema forms in this order. + pub content_schema: Option>, + pub picker: Option, + pub client: Option, + pub server: Option, + pub cli: Option, +} + +impl ExtensionManifest { + /// Serialize to the zod-output shape (`Json.parse(JSON.stringify( + /// result.data))`): camelCase keys, absent optionals elided, materialized + /// defaults present. This is the shape the oracle fixture pins. + pub fn to_zod_output_value(&self) -> Value { + let mut m = Map::new(); + m.insert("name".into(), Value::String(self.name.clone())); + m.insert("version".into(), Value::String(self.version.clone())); + m.insert("label".into(), Value::String(self.label.clone())); + m.insert( + "description".into(), + Value::String(self.description.clone()), + ); + m.insert( + "category".into(), + Value::String(self.category.as_str().into()), + ); + if let Some(v) = &self.icon { + m.insert("icon".into(), Value::String(v.clone())); + } + if let Some(v) = &self.url { + m.insert("url".into(), Value::String(v.clone())); + } + if let Some(v) = &self.content_schema { + let mut cs = Map::new(); + for (k, f) in v { + cs.insert(k.clone(), serde_json::to_value(f).unwrap_or(Value::Null)); + } + m.insert("contentSchema".into(), Value::Object(cs)); + } + if let Some(v) = &self.picker { + m.insert( + "picker".into(), + serde_json::to_value(v).unwrap_or(Value::Null), + ); + } + if let Some(v) = &self.client { + m.insert( + "client".into(), + serde_json::to_value(v).unwrap_or(Value::Null), + ); + } + if let Some(v) = &self.server { + m.insert( + "server".into(), + serde_json::to_value(v).unwrap_or(Value::Null), + ); + } + if let Some(v) = &self.cli { + m.insert("cli".into(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + Value::Object(m) + } +} + +impl Serialize for ExtensionManifest { + fn serialize(&self, s: S) -> Result { + self.to_zod_output_value().serialize(s) + } +} diff --git a/crates/freshell-extensions/src/validate.rs b/crates/freshell-extensions/src/validate.rs new file mode 100644 index 000000000..84a141cc5 --- /dev/null +++ b/crates/freshell-extensions/src/validate.rs @@ -0,0 +1,973 @@ +//! The strict manifest validator — a hand-written `serde_json::Value` walker +//! replicating zod-4.3.6 semantics exactly (see crate docs and +//! `docs/plans/df1/EXT-01.md` DC-4 for the pinned rule set). +//! +//! Design calls baked in here: +//! * DC-2 — validate from `Value`, never derive-`Deserialize` (duplicate JSON +//! keys must last-win like `JSON.parse`; literal `null` for `.optional()` +//! fields must REJECT, not coerce to absent). +//! * DC-3 — issues are zod's flattened `{code, path, message}` triples with +//! byte-exact 4.3.6 message text (the legacy `.format()` log nesting is +//! intentionally not reproduced; content parity, shape flattened). +//! * DC-4 — emission order: object members in SCHEMA-DEFINITION order, +//! `unrecognized_keys` last within each object, refines after their object's +//! base issues; refine gating: refines run iff their subtree accumulated no +//! ABORTING issue (invalid_type | invalid_value | invalid_union | +//! unrecognized_keys); check codes (too_small | too_big) never gate. + +use indexmap::IndexMap; +use serde_json::{Map, Value}; + +use crate::issue::{IssueCode, ManifestError, ManifestIssue, PathSeg}; +use crate::manifest::{ + Category, CliConfig, ClientConfig, ContentSchemaField, DefaultValue, ExtensionManifest, + FieldType, PickerConfig, PreferredRenderer, ScrollInputPolicy, ServerConfig, TerminalBehavior, +}; + +/// Parse+validate manifest file TEXT — the full legacy flow +/// (`JSON.parse(raw)` → `ExtensionManifestSchema.safeParse(json)`) in one +/// call. `serde_json::from_str::` matches `JSON.parse` on duplicate +/// keys (last wins) and number rounding (IEEE-754 nearest). +pub fn parse_manifest(json_text: &str) -> Result { + let value: Value = + serde_json::from_str(json_text).map_err(|e| ManifestError::InvalidJson(e.to_string()))?; + validate_manifest(&value).map_err(ManifestError::Invalid) +} + +/// Validate an already-parsed JSON value — legacy `safeParse(json)`. +pub fn validate_manifest(value: &Value) -> Result> { + Validator::new().validate(value) +} + +// ────────────────────────────────────────────────────────────── +// Messages (byte-exact zod 4.3.6 text — pinned by the oracle fixture) +// ────────────────────────────────────────────────────────────── + +fn received_name(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +fn msg_invalid_type(expected: &str, received: &str) -> String { + format!("Invalid input: expected {expected}, received {received}") +} + +const MSG_FIELD_DEFAULT_TYPE: &str = "default value must match the declared field type"; +const MSG_CATEGORY_BLOCK: &str = "category must have exactly its own config block (no others)"; + +/// JS-safe-int bounds from zod-4 `.int()` (Number.MAX_SAFE_INTEGER). +const SAFE_INT_MIN_F: f64 = -9007199254740991.0; +const SAFE_INT_MAX_F: f64 = 9007199254740991.0; + +// ── JS object enumeration semantics (verified against zod 4.3.6 behavior) ── +// +// Two JS object-key behaviors are observable in zod's output and must be +// reproduced when validating `serde_json::Value` objects: +// +// 1. **JS own-key enumeration order** (`for…in` / `Reflect.ownKeys` over the +// plain objects `JSON.parse` produces): canonical array-index keys FIRST in +// ascending numeric order, then the remaining string keys in insertion +// order. Affects the `unrecognized_keys` message member order and the +// iteration/output order of records (contentSchema, env, +// permissionModeValues). +// 2. **`__proto__` is silently skipped in `z.record(...)` values** +// (`$ZodRecord` explicitly continues on it) but NOT in strict objects +// (there it surfaces as a normal `unrecognized_keys` member). Skipping +// means: never validated, never kept in output. + +/// A canonical JS array-index key: all ASCII digits, no leading zeros (the +/// `ToString(ToNumber(k)) === k` canonicality rule), value < 2^32-1. +fn js_array_index(k: &str) -> Option { + if k.is_empty() || !k.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let n: u64 = k.parse().ok()?; + if n >= 4294967295 || n.to_string() != k { + return None; + } + Some(n as u32) +} + +/// JS own-key enumeration order over a parsed JSON object. +fn js_ordered_keys(m: &Map) -> Vec<&str> { + let mut indexed: Vec<(u32, &str)> = Vec::new(); + let mut rest: Vec<&str> = Vec::new(); + for k in m.keys() { + match js_array_index(k) { + Some(n) => indexed.push((n, k)), + None => rest.push(k), + } + } + indexed.sort_by_key(|(n, _)| *n); + indexed.into_iter().map(|(_, k)| k).chain(rest).collect() +} + +// ────────────────────────────────────────────────────────────── +// The validator +// ────────────────────────────────────────────────────────────── + +struct Validator { + issues: Vec, + /// Path cursor: the shared prefix for every pushed issue. + path: Vec, +} + +/// Tri-state result of checking one property: `Good(v)` | `Bad` (issue(s) +/// already pushed) | `Absent` (key not in the object — valid for optionals +/// only). Required fields map `Absent` to `Bad` at their call sites. +enum Check { + Good(T), + Bad, + Absent, +} + +impl Check { + fn is_bad(&self) -> bool { + matches!(self, Check::Bad) + } +} + +macro_rules! bad { + ($($c:expr),+ $(,)?) => { + $($c.is_bad())||+ + }; +} + +impl Validator { + fn new() -> Self { + Validator { + issues: Vec::new(), + path: Vec::new(), + } + } + + fn push(&mut self, code: IssueCode, message: String) { + self.issues.push(ManifestIssue { + code, + path: self.path.clone(), + message, + }); + } + + /// True iff the issues emitted since `mark` contain an aborting issue + /// (DC-4.2 — the refine-gating rule). + fn aborted_since(&self, mark: usize) -> bool { + self.issues[mark..].iter().any(|i| i.code.is_aborting()) + } + + fn validate(mut self, value: &Value) -> Result> { + let Value::Object(obj) = value else { + self.push( + IssueCode::InvalidType, + msg_invalid_type("object", received_name(value)), + ); + return Err(self.issues); + }; + + // Member validation in SCHEMA-DEFINITION order + // (extension-manifest.ts:81-103): property issues never follow input + // member order (DC-4.1). + let name = self.req_str(obj, "name", Min::One); + let version = self.req_str(obj, "version", Min::One); + let label = self.req_str(obj, "label", Min::One); + let description = self.req_str(obj, "description", Min::One); + let category = self.req_enum(obj, "category", Category::OPTIONS); + let icon = self.opt_str(obj, "icon"); + let url = self.opt_str(obj, "url"); + let content_schema = self.opt_content_schema(obj, "contentSchema"); + let picker = self.opt_picker(obj, "picker"); + let client = self.opt_client(obj, "client"); + let server = self.opt_server(obj, "server"); + let cli = self.opt_cli(obj, "cli"); + + // strictObject: any other key is rejected (DC-4.1: after members). + self.unrecognized( + obj, + &[ + "name", + "version", + "label", + "description", + "category", + "icon", + "url", + "contentSchema", + "picker", + "client", + "server", + "cli", + ], + ); + + // The category↔block refine (extension-manifest.ts:96-103), gated by + // the abort rule (DC-4.2) over the WHOLE manifest subtree. Presence + // is best-effort RAW-KEY presence: a block that produced only + // check-level failures (e.g. empty command) still counts as present. + if !self.aborted_since(0) { + let present: Vec<&str> = ["client", "server", "cli"] + .into_iter() + .filter(|k| obj.contains_key(*k)) + .collect(); + let matches = present.len() == 1 + && matches!(&category, Check::Good(c) if present[0] == c.as_str()); + if !matches { + self.push(IssueCode::Custom, MSG_CATEGORY_BLOCK.into()); + } + } + + if !self.issues.is_empty() { + return Err(self.issues); + } + + // Invariant: with zero issues every required member is Good and every + // optional resolved to Absent or Good — anything else is a validator + // bug, not a manifest problem, so panic loudly in that case. + let unwrap = |c: Check, field: &str| match c { + Check::Good(v) => v, + _ => unreachable!("zero-issue manifest must not have Bad/missing {field}"), + }; + Ok(ExtensionManifest { + name: unwrap(name, "name"), + version: unwrap(version, "version"), + label: unwrap(label, "label"), + description: unwrap(description, "description"), + category: match category { + Check::Good(c) => c, + _ => unreachable!("zero-issue manifest must have a valid category"), + }, + icon: opt_out(icon), + url: opt_out(url), + content_schema: opt_out(content_schema), + picker: opt_out(picker), + client: opt_out(client), + server: opt_out(server), + cli: opt_out(cli), + }) + } + + // ── Scalar property checkers ──────────────────────────────────────────── + + /// `z.string()` with optional `.min(1)`. `min(1)` rejects only the EMPTY + /// string; whitespace-only strings pass. + fn str_prop(&mut self, obj: &Map, key: &str, min: Min) -> Check { + let Some(v) = obj.get(key) else { + return Check::Absent; + }; + self.path.push(PathSeg::Key(key.into())); + let out = match v { + Value::String(s) => { + if min == Min::One && s.is_empty() { + self.push( + IssueCode::TooSmall, + "Too small: expected string to have >=1 characters".into(), + ); + Check::Bad + } else { + Check::Good(s.clone()) + } + } + other => { + self.push( + IssueCode::InvalidType, + msg_invalid_type("string", received_name(other)), + ); + Check::Bad + } + }; + self.path.pop(); + out + } + + fn req_str(&mut self, obj: &Map, key: &str, min: Min) -> Check { + match self.str_prop(obj, key, min) { + Check::Absent => { + self.path.push(PathSeg::Key(key.into())); + self.push( + IssueCode::InvalidType, + msg_invalid_type("string", "undefined"), + ); + self.path.pop(); + Check::Bad + } + other => other, + } + } + + fn opt_str(&mut self, obj: &Map, key: &str) -> Check> { + match self.str_prop(obj, key, Min::Zero) { + Check::Absent => Check::Absent, + Check::Bad => Check::Bad, + Check::Good(v) => Check::Good(Some(v)), + } + } + + /// `z.enum([...])`: zod-4 collapses missing AND wrong-type AND wrong-value + /// to ONE `invalid_value` issue (probed + fixture-pinned; never + /// invalid_type), with the single-option message form when there is one + /// option. + fn enum_prop( + &mut self, + obj: &Map, + key: &str, + options: &[(&str, T)], + required: bool, + ) -> Check { + let Some(v) = obj.get(key) else { + return if required { + self.path.push(PathSeg::Key(key.into())); + self.push(IssueCode::InvalidValue, msg_enum(options)); + self.path.pop(); + Check::Bad + } else { + Check::Absent + }; + }; + if let Value::String(s) = v { + if let Some((_, val)) = options.iter().find(|(name, _)| *name == s) { + return Check::Good(*val); + } + } + self.path.push(PathSeg::Key(key.into())); + self.push(IssueCode::InvalidValue, msg_enum(options)); + self.path.pop(); + Check::Bad + } + + fn req_enum( + &mut self, + obj: &Map, + key: &str, + options: &[(&str, T)], + ) -> Check { + self.enum_prop(obj, key, options, true) + } + + fn opt_enum( + &mut self, + obj: &Map, + key: &str, + options: &[(&str, T)], + ) -> Check> { + match self.enum_prop(obj, key, options, false) { + Check::Absent => Check::Absent, + Check::Bad => Check::Bad, + Check::Good(v) => Check::Good(Some(v)), + } + } + + fn opt_bool(&mut self, obj: &Map, key: &str) -> Check> { + let Some(v) = obj.get(key) else { + return Check::Absent; + }; + match v { + Value::Bool(b) => Check::Good(Some(*b)), + other => { + self.path.push(PathSeg::Key(key.into())); + self.push( + IssueCode::InvalidType, + msg_invalid_type("boolean", received_name(other)), + ); + self.path.pop(); + Check::Bad + } + } + } + + /// `z.array(z.string())` — every non-string element reports at its + /// numeric index (path `[..., key, idx]`). + fn opt_str_array(&mut self, obj: &Map, key: &str) -> Check>> { + let Some(v) = obj.get(key) else { + return Check::Absent; + }; + self.path.push(PathSeg::Key(key.into())); + let out = match v { + Value::Array(items) => { + let mut acc = Vec::with_capacity(items.len()); + let mut ok = true; + for (i, item) in items.iter().enumerate() { + match item { + Value::String(s) => acc.push(s.clone()), + other => { + self.path.push(PathSeg::Index(i as u32)); + self.push( + IssueCode::InvalidType, + msg_invalid_type("string", received_name(other)), + ); + self.path.pop(); + ok = false; + } + } + } + if ok { + Check::Good(Some(acc)) + } else { + Check::Bad + } + } + other => { + self.push( + IssueCode::InvalidType, + msg_invalid_type("array", received_name(other)), + ); + Check::Bad + } + }; + self.path.pop(); + out + } + + /// `z.record(z.string(), z.string())` — non-objects report with zod's + /// `record` expected-type word; bad values report at `[..., key, entry]`. + /// Entries iterate in JS own-key order; `__proto__` is silently SKIPPED + /// per `$ZodRecord` (never validated, never kept). + fn opt_str_record( + &mut self, + obj: &Map, + key: &str, + ) -> Check>> { + let Some(v) = obj.get(key) else { + return Check::Absent; + }; + self.path.push(PathSeg::Key(key.into())); + let out = match v { + Value::Object(entries) => { + let mut acc = IndexMap::with_capacity(entries.len()); + let mut ok = true; + for k in js_ordered_keys(entries) { + if k == "__proto__" { + continue; + } + let entry = &entries[k]; + match entry { + Value::String(s) => { + acc.insert(k.to_string(), s.clone()); + } + other => { + self.path.push(PathSeg::Key(k.to_string())); + self.push( + IssueCode::InvalidType, + msg_invalid_type("string", received_name(other)), + ); + self.path.pop(); + ok = false; + } + } + } + if ok { + Check::Good(Some(acc)) + } else { + Check::Bad + } + } + other => { + self.push( + IssueCode::InvalidType, + msg_invalid_type("record", received_name(other)), + ); + Check::Bad + } + }; + self.path.pop(); + out + } + + /// `z.number().int().positive()` for `readyTimeout`. zod-4 `.int()` is + /// JS-safe-int: non-number → invalid_type ("expected number"); + /// non-integral → invalid_type ("expected int", chain-aborting); integral + /// but out of `±(2^53-1)` → too_small/too_big (ACCUMULATING with the + /// positive check, which runs after). + fn opt_ready_timeout(&mut self, obj: &Map, key: &str) -> Check> { + let Some(v) = obj.get(key) else { + return Check::Absent; + }; + self.path.push(PathSeg::Key(key.into())); + let out = match v { + Value::Number(n) => { + let f = n.as_f64().expect("serde_json::Number is always f64-able"); + if f.fract() != 0.0 { + // Type/format failure: aborts the check chain (DC-4.3). + self.push(IssueCode::InvalidType, msg_invalid_type("int", "number")); + Check::Bad + } else { + let mut ok = true; + if f < SAFE_INT_MIN_F { + self.push( + IssueCode::TooSmall, + "Too small: expected int to be >=-9007199254740991".into(), + ); + ok = false; + } else if f > SAFE_INT_MAX_F { + self.push( + IssueCode::TooBig, + "Too big: expected int to be <=9007199254740991".into(), + ); + ok = false; + } + // NaN is unreachable (serde_json never produces it), so + // `f <= 0.0` ≡ zod's `!(f > 0)` positivity failure. + if f <= 0.0 { + self.push( + IssueCode::TooSmall, + "Too small: expected number to be >0".into(), + ); + ok = false; + } + if ok { + // f integral in 1..=2^53-1 fits u64 exactly. + Check::Good(Some(f as u64)) + } else { + Check::Bad + } + } + } + other => { + self.push( + IssueCode::InvalidType, + msg_invalid_type("number", received_name(other)), + ); + Check::Bad + } + }; + self.path.pop(); + out + } + + // ── Object property checkers ──────────────────────────────────────────── + + /// Shared strict-object prelude for optional sub-blocks: absent → Absent; + /// non-object (incl. literal null — `.optional()` never accepts null) → + /// invalid_type "expected object"; object → hands the map to `inner`. + fn opt_object( + &mut self, + obj: &Map, + key: &str, + inner: impl FnOnce(&mut Self, &Map) -> T, + ) -> Check> { + let Some(v) = obj.get(key) else { + return Check::Absent; + }; + self.path.push(PathSeg::Key(key.into())); + let out = match v { + Value::Object(m) => Check::Good(Some(inner(self, m))), + other => { + self.push( + IssueCode::InvalidType, + msg_invalid_type("object", received_name(other)), + ); + Check::Bad + } + }; + self.path.pop(); + out + } + + /// strictObject tail: one `unrecognized_keys` issue listing every unknown + /// key in JS own-key order (canonical array-index keys ascending, then + /// insertion order — `for…in` over a `JSON.parse` object), + /// singular/plural message forms. `__proto__` is NOT special here (it is + /// a normal unrecognized key for strict objects). + fn unrecognized(&mut self, obj: &Map, known: &[&str]) { + let mut unknown: Vec<&str> = Vec::new(); + for k in js_ordered_keys(obj) { + if !known.contains(&k) { + unknown.push(k); + } + } + match unknown.as_slice() { + [] => {} + [one] => self.push( + IssueCode::UnrecognizedKeys, + format!("Unrecognized key: \"{one}\""), + ), + many => { + let list = many + .iter() + .map(|k| format!("\"{k}\"")) + .collect::>() + .join(", "); + self.push( + IssueCode::UnrecognizedKeys, + format!("Unrecognized keys: {list}"), + ); + } + } + } + + fn opt_picker(&mut self, obj: &Map, key: &str) -> Check> { + self.opt_object(obj, key, |v, m| { + // definition order: shortcut, group (extension-manifest.ts:72-75) + let shortcut = v.opt_str(m, "shortcut"); + let group = v.opt_str(m, "group"); + v.unrecognized(m, &["shortcut", "group"]); + if bad!(shortcut, group) { + return None; + } + Some(PickerConfig { + shortcut: opt_out(shortcut), + group: opt_out(group), + }) + }) + .into_flat() + } + + fn opt_client(&mut self, obj: &Map, key: &str) -> Check> { + self.opt_object(obj, key, |v, m| { + let entry = v.req_str(m, "entry", Min::One); + v.unrecognized(m, &["entry"]); + match entry { + Check::Good(entry) => Some(ClientConfig { entry }), + _ => None, + } + }) + .into_flat() + } + + fn opt_server(&mut self, obj: &Map, key: &str) -> Check> { + self.opt_object(obj, key, |v, m| { + // definition order (extension-manifest.ts:35-43) + let command = v.req_str(m, "command", Min::One); + let args = v.opt_str_array(m, "args"); + let env = v.opt_str_record(m, "env"); + let ready_pattern = v.opt_str(m, "readyPattern"); + let ready_timeout = v.opt_ready_timeout(m, "readyTimeout"); + let health_check = v.opt_str(m, "healthCheck"); + let singleton = v.opt_bool(m, "singleton"); + v.unrecognized( + m, + &[ + "command", + "args", + "env", + "readyPattern", + "readyTimeout", + "healthCheck", + "singleton", + ], + ); + if bad!( + command, + args, + env, + ready_pattern, + ready_timeout, + health_check, + singleton + ) { + return None; + } + let Check::Good(command) = command else { + return None; + }; + Some(ServerConfig { + command, + // zod defaults materialize here (extension-manifest.ts:37,40,42). + args: opt_out(args).unwrap_or_default(), + env: opt_out(env), + ready_pattern: opt_out(ready_pattern), + ready_timeout: opt_out(ready_timeout).unwrap_or(10000), + health_check: opt_out(health_check), + singleton: opt_out(singleton).unwrap_or(true), + }) + }) + .into_flat() + } + + fn opt_cli(&mut self, obj: &Map, key: &str) -> Check> { + self.opt_object(obj, key, |v, m| { + // definition order (extension-manifest.ts:50-66) + let command = v.req_str(m, "command", Min::One); + let args = v.opt_str_array(m, "args"); + let env = v.opt_str_record(m, "env"); + let env_var = v.opt_str(m, "envVar"); + let resume_args = v.opt_str_array(m, "resumeArgs"); + let create_session_args = v.opt_str_array(m, "createSessionArgs"); + let model_args = v.opt_str_array(m, "modelArgs"); + let sandbox_args = v.opt_str_array(m, "sandboxArgs"); + let permission_mode_args = v.opt_str_array(m, "permissionModeArgs"); + let permission_mode_env_var = v.opt_str(m, "permissionModeEnvVar"); + let permission_mode_values = v.opt_str_record(m, "permissionModeValues"); + let supports_permission_mode = v.opt_bool(m, "supportsPermissionMode"); + let supports_model = v.opt_bool(m, "supportsModel"); + let supports_sandbox = v.opt_bool(m, "supportsSandbox"); + let terminal_behavior = v + .opt_object(m, "terminalBehavior", |v, m| { + let preferred_renderer = + v.opt_enum(m, "preferredRenderer", PreferredRenderer::OPTIONS); + let scroll_input_policy = + v.opt_enum(m, "scrollInputPolicy", ScrollInputPolicy::OPTIONS); + v.unrecognized(m, &["preferredRenderer", "scrollInputPolicy"]); + if bad!(preferred_renderer, scroll_input_policy) { + return None; + } + Some(TerminalBehavior { + preferred_renderer: opt_out(preferred_renderer), + scroll_input_policy: opt_out(scroll_input_policy), + }) + }) + .into_flat(); + v.unrecognized( + m, + &[ + "command", + "args", + "env", + "envVar", + "resumeArgs", + "createSessionArgs", + "modelArgs", + "sandboxArgs", + "permissionModeArgs", + "permissionModeEnvVar", + "permissionModeValues", + "supportsPermissionMode", + "supportsModel", + "supportsSandbox", + "terminalBehavior", + ], + ); + if bad!( + command, + args, + env, + env_var, + resume_args, + create_session_args, + model_args, + sandbox_args, + permission_mode_args, + permission_mode_env_var, + permission_mode_values, + supports_permission_mode, + supports_model, + supports_sandbox, + terminal_behavior + ) { + return None; + } + let Check::Good(command) = command else { + return None; + }; + Some(CliConfig { + command, + // zod default materializes here (extension-manifest.ts:52). + args: opt_out(args).unwrap_or_default(), + env: opt_out(env), + env_var: opt_out(env_var), + resume_args: opt_out(resume_args), + create_session_args: opt_out(create_session_args), + model_args: opt_out(model_args), + sandbox_args: opt_out(sandbox_args), + permission_mode_args: opt_out(permission_mode_args), + permission_mode_env_var: opt_out(permission_mode_env_var), + permission_mode_values: opt_out(permission_mode_values), + supports_permission_mode: opt_out(supports_permission_mode), + supports_model: opt_out(supports_model), + supports_sandbox: opt_out(supports_sandbox), + terminal_behavior: opt_out(terminal_behavior), + }) + }) + .into_flat() + } + + fn opt_content_schema( + &mut self, + obj: &Map, + key: &str, + ) -> Check>> { + let Some(v) = obj.get(key) else { + return Check::Absent; + }; + self.path.push(PathSeg::Key(key.into())); + let out = match v { + Value::Object(entries) => { + let mut acc = IndexMap::with_capacity(entries.len()); + let mut ok = true; + // Record entries iterate in JS own-key order (array-index keys + // ascending first); `__proto__` is silently skipped + // (never validated, never kept) per `$ZodRecord`. + for field_key in js_ordered_keys(entries) { + if field_key == "__proto__" { + continue; + } + let field_val = &entries[field_key]; + self.path.push(PathSeg::Key(field_key.to_string())); + match self.content_schema_field(field_val) { + Some(field) => { + acc.insert(field_key.to_string(), field); + } + None => ok = false, + } + self.path.pop(); + } + if ok { + Check::Good(Some(acc)) + } else { + Check::Bad + } + } + other => { + self.push( + IssueCode::InvalidType, + msg_invalid_type("record", received_name(other)), + ); + Check::Bad + } + }; + self.path.pop(); + out + } + + /// One `ContentSchemaFieldSchema` value (path cursor already AT the + /// field key). Returns None when any issue was pushed for this field. + fn content_schema_field(&mut self, value: &Value) -> Option { + let mark = self.issues.len(); + let Value::Object(m) = value else { + self.push( + IssueCode::InvalidType, + msg_invalid_type("object", received_name(value)), + ); + return None; + }; + // definition order (extension-manifest.ts:14-18) + let field_type = self.req_enum(m, "type", FieldType::OPTIONS); + let label = self.req_str(m, "label", Min::Zero); + let required = self.opt_bool(m, "required"); + let default = self.opt_default(m, "default"); + self.unrecognized(m, &["type", "label", "required", "default"]); + + // The field-type refine (extension-manifest.ts:19-25), gated by the + // abort rule over THIS FIELD's subtree only (DC-4.2). + if !self.aborted_since(mark) { + if let (Check::Good(ft), Check::Good(Some(d))) = (&field_type, &default) { + if d.js_typeof() != ft.as_str() { + self.push(IssueCode::Custom, MSG_FIELD_DEFAULT_TYPE.into()); + } + } + } + + if bad!(field_type, label, required, default) { + return None; + } + let (Check::Good(field_type), Check::Good(label)) = (field_type, label) else { + return None; + }; + Some(ContentSchemaField { + field_type, + label, + required: opt_out(required), + default: opt_out(default), + }) + } + + /// `z.union([z.string(), z.number(), z.boolean()])` for the field + /// default: string/number/boolean accepted, everything else (incl. null, + /// arrays, objects) → one `invalid_union` "Invalid input". + fn opt_default(&mut self, obj: &Map, key: &str) -> Check> { + let Some(v) = obj.get(key) else { + return Check::Absent; + }; + match v { + Value::String(s) => Check::Good(Some(DefaultValue::String(s.clone()))), + Value::Number(n) => Check::Good(Some(DefaultValue::number(n))), + Value::Bool(b) => Check::Good(Some(DefaultValue::Boolean(*b))), + _ => { + self.path.push(PathSeg::Key(key.into())); + self.push(IssueCode::InvalidUnion, "Invalid input".into()); + self.path.pop(); + Check::Bad + } + } + } +} + +/// `opt_object` wraps its closure's value in `Some`, so a closure returning +/// `Option` (None = a member failed; issue already pushed) yields +/// `Check>>`. Flatten back to `Check>`: +/// `Good(Some(None))` (member failure) is `Bad`, NOT a present block. +trait IntoFlat { + fn into_flat(self) -> Check>; +} + +impl IntoFlat for Check>> { + fn into_flat(self) -> Check> { + match self { + Check::Good(inner) => match inner { + Some(Some(v)) => Check::Good(Some(v)), + // Member-level failure (issue already pushed), or the + // never-produced bare Good(None) — either way not a good block. + Some(None) | None => Check::Bad, + }, + Check::Bad => Check::Bad, + Check::Absent => Check::Absent, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Min { + Zero, + One, +} + +fn msg_enum(options: &[(&str, T)]) -> String { + if options.len() == 1 { + format!("Invalid input: expected \"{}\"", options[0].0) + } else { + let list = options + .iter() + .map(|(n, _)| format!("\"{n}\"")) + .collect::>() + .join("|"); + format!("Invalid option: expected one of {list}") + } +} + +/// Flatten the optional tri-state into the output `Option` (Bad is +/// unreachable at assembly time — guarded by the zero-issue invariant). +fn opt_out(c: Check>) -> Option { + match c { + Check::Good(v) => v, + Check::Absent => None, + Check::Bad => unreachable!("zero-issue manifest must not have Bad optionals"), + } +} + +impl Category { + const OPTIONS: &'static [(&'static str, Category)] = &[ + ("client", Category::Client), + ("server", Category::Server), + ("cli", Category::Cli), + ]; +} + +impl FieldType { + const OPTIONS: &'static [(&'static str, FieldType)] = &[ + ("string", FieldType::String), + ("number", FieldType::Number), + ("boolean", FieldType::Boolean), + ]; +} + +impl PreferredRenderer { + const OPTIONS: &'static [(&'static str, PreferredRenderer)] = + &[("canvas", PreferredRenderer::Canvas)]; +} + +impl ScrollInputPolicy { + const OPTIONS: &'static [(&'static str, ScrollInputPolicy)] = &[ + ("native", ScrollInputPolicy::Native), + ( + "fallbackToCursorKeysWhenAltScreenMouseCapture", + ScrollInputPolicy::FallbackToCursorKeysWhenAltScreenMouseCapture, + ), + ]; +} + +#[cfg(test)] +mod tests; diff --git a/crates/freshell-extensions/src/validate/tests.rs b/crates/freshell-extensions/src/validate/tests.rs new file mode 100644 index 000000000..fd7fcd0df --- /dev/null +++ b/crates/freshell-extensions/src/validate/tests.rs @@ -0,0 +1,262 @@ +//! Focused unit tests for the strict validator (`super`): properties the +//! differential oracle cannot express — error-class distinctions at the API +//! boundary, issue Display formatting (what scan logs), JS own-key ORDER +//! fidelity (oracle JSON equality is order-insensitive), and the `__proto__` +//! record-skip shape. +//! +//! Verdict/message behavior is NOT unit-tested here on purpose — the +//! oracle fixture pins all 130 cases against the real zod schema. +use super::*; +use crate::manifest::FieldType; + +/// `parse_manifest` distinguishes legacy's two scan log classes: +/// 'invalid JSON in manifest' (text not JSON) vs 'invalid manifest' +/// (schema failure). +#[test] +fn parse_manifest_splits_invalid_json_from_invalid_manifest() { + let err = parse_manifest("{ not json").unwrap_err(); + assert!(matches!(err, ManifestError::InvalidJson(_)), "{err:?}"); + + let err = parse_manifest(r#"{"name": 5}"#).unwrap_err(); + match err { + ManifestError::Invalid(issues) => { + // name + missing version/label/description/category; the + // category refine is gated by the aborting failures. + assert_eq!(issues.len(), 5, "{issues:?}"); + assert_eq!(issues[0].code, IssueCode::InvalidType); + assert_eq!(issues[0].path, vec![PathSeg::Key("name".into())]); + } + other => panic!("expected Invalid, got {other:?}"), + } +} + +#[test] +fn issue_display_is_log_friendly() { + let err = parse_manifest( + r#"{"name":"x","version":"1","label":"l","description":"d","category":"cli","cli":{"command":"c","flags":[]}}"#, + ) + .unwrap_err(); + let ManifestError::Invalid(issues) = err else { + unreachable!() + }; + assert_eq!( + issues[0].to_string(), + "[unrecognized_keys cli] Unrecognized key: \"flags\"" + ); +} + +/// contentSchema preserves manifest TEXT field order (JS object insertion +/// order) — the client renders the form in this order. Value-equality +/// (used by the oracle) is order-insensitive, so this pins the text. +#[test] +fn content_schema_output_preserves_manifest_field_order() { + let manifest = parse_manifest( + r#"{ + "name": "x", "version": "1", "label": "l", "description": "d", + "category": "client", "client": { "entry": "e" }, + "contentSchema": { + "zebra": { "type": "string", "label": "Z" }, + "apple": { "type": "number", "label": "A" }, + "mango": { "type": "boolean", "label": "M" } + } + }"#, + ) + .expect("valid"); + let text = serde_json::to_string(&manifest.to_zod_output_value()).unwrap(); + let (z, a, m) = ( + text.find("zebra").unwrap(), + text.find("apple").unwrap(), + text.find("mango").unwrap(), + ); + assert!(z < a && a < m, "insertion order must survive: {text}"); + // And the typed model exposes the same order. + let keys: Vec<&String> = manifest.content_schema.as_ref().unwrap().keys().collect(); + assert_eq!(keys, ["zebra", "apple", "mango"]); +} + +/// env/permissionModeValues records preserve manifest text order too +/// (IndexMap, not BTreeMap). +#[test] +fn env_record_output_preserves_manifest_order() { + let manifest = parse_manifest( + r#"{ + "name": "x", "version": "1", "label": "l", "description": "d", + "category": "cli", + "cli": { "command": "c", "env": { "ZED": "1", "ALPHA": "2" } } + }"#, + ) + .expect("valid"); + let keys: Vec<&String> = manifest + .cli + .as_ref() + .unwrap() + .env + .as_ref() + .unwrap() + .keys() + .collect(); + assert_eq!(keys, ["ZED", "ALPHA"]); +} + +/// JS own-key enumeration order (from the df1 independent review): +/// canonical array-index keys enumerate FIRST in ascending numeric order, +/// then other keys in insertion order — for records (env shown), for +/// contentSchema, and for the unrecognized_keys message. zod enumerates +/// `JSON.parse` objects this way (for…in / Reflect.ownKeys). +#[test] +fn env_record_uses_js_own_key_order_for_numeric_keys() { + let manifest = parse_manifest( + r#"{ + "name": "x", "version": "1", "label": "l", "description": "d", + "category": "cli", + "cli": { "command": "c", "env": { "10": "a", "2": "b", "x": "c", "00": "d" } } + }"#, + ) + .expect("valid"); + // "2" before "10" (numeric), then insertion order for the rest + // ("00" is NOT a canonical array index). + let keys: Vec<&String> = manifest + .cli + .as_ref() + .unwrap() + .env + .as_ref() + .unwrap() + .keys() + .collect(); + assert_eq!(keys, ["2", "10", "x", "00"]); +} + +#[test] +fn content_schema_uses_js_own_key_order_for_numeric_keys() { + let manifest = parse_manifest( + r#"{ + "name": "x", "version": "1", "label": "l", "description": "d", + "category": "client", "client": { "entry": "e" }, + "contentSchema": { + "zebra": { "type": "string", "label": "Z" }, + "10": { "type": "string", "label": "ten" }, + "2": { "type": "string", "label": "two" } + } + }"#, + ) + .expect("valid"); + let keys: Vec<&String> = manifest.content_schema.as_ref().unwrap().keys().collect(); + assert_eq!(keys, ["2", "10", "zebra"]); +} + +/// `$ZodRecord` silently skips `__proto__`: never validated (even an +/// invalid value passes), never kept in output. Strict objects still +/// reject it as an unrecognized key (a different code path). +#[test] +fn proto_key_is_skipped_in_records_but_rejected_in_strict_objects() { + let manifest = parse_manifest( + r#"{ + "name": "x", "version": "1", "label": "l", "description": "d", + "category": "cli", + "cli": { "command": "c", "env": { "__proto__": 5, "x": "y" } } + }"#, + ) + .expect("__proto__ with an invalid value is skipped, not validated"); + let env = manifest.cli.as_ref().unwrap().env.as_ref().unwrap(); + assert_eq!(env.len(), 1, "__proto__ dropped from output"); + assert_eq!(env["x"], "y"); + + let err = parse_manifest( + r#"{ + "name": "x", "version": "1", "label": "l", "description": "d", + "category": "cli", "cli": { "command": "c" }, "__proto__": 1 + }"#, + ) + .unwrap_err(); + let ManifestError::Invalid(issues) = err else { + unreachable!() + }; + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].code, IssueCode::UnrecognizedKeys); + assert_eq!(issues[0].message, "Unrecognized key: \"__proto__\""); +} + +/// The content-schema default union reports ONE invalid_union for +/// non-scalar defaults (not three member failures) and does NOT fire the +/// field refine afterwards (invalid_union is aborting → refine gated). +#[test] +fn union_default_failure_is_single_invalid_union_no_refine() { + let err = parse_manifest( + r#"{ + "name": "x", "version": "1", "label": "l", "description": "d", + "category": "client", "client": { "entry": "e" }, + "contentSchema": { "f": { "type": "string", "label": "L", "default": {} } } + }"#, + ) + .unwrap_err(); + let ManifestError::Invalid(issues) = err else { + unreachable!() + }; + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].code, IssueCode::InvalidUnion); + assert_eq!(issues[0].message, "Invalid input"); + assert_eq!( + issues[0].path, + vec![ + PathSeg::Key("contentSchema".into()), + PathSeg::Key("f".into()), + PathSeg::Key("default".into()), + ] + ); +} + +/// Data accessibility smoke: a fully-populated CLI manifest exposes every +/// launch/permission/model/sandbox field through the typed model and the +/// zod-output shape mirrors the input key-for-key (plus materialized +/// args). +#[test] +fn cli_full_surface_round_trips_key_for_key() { + let input = r#"{ + "name": "opencode", "version": "1.0.0", "label": "OpenCode", + "description": "x", "category": "cli", + "cli": { + "command": "opencode", "args": ["--ui"], + "env": { "A": "1" }, "envVar": "OPENCODE_CMD", + "resumeArgs": ["--session", "{{sessionId}}"], + "createSessionArgs": ["--session-id", "{{sessionId}}"], + "modelArgs": ["--model", "{{model}}"], + "sandboxArgs": ["--sandbox", "{{sandbox}}"], + "permissionModeArgs": ["--permission-mode", "{{permissionMode}}"], + "permissionModeEnvVar": "AGENT_PERMISSION_MODE", + "permissionModeValues": { "plan": "{}" }, + "supportsPermissionMode": true, "supportsModel": true, "supportsSandbox": false, + "terminalBehavior": { "preferredRenderer": "canvas", "scrollInputPolicy": "native" } + } + }"#; + let manifest = parse_manifest(input).expect("valid"); + let cli = manifest.cli.as_ref().unwrap(); + assert_eq!(cli.command, "opencode"); + assert_eq!(cli.args, ["--ui"]); + assert_eq!(cli.env_var.as_deref(), Some("OPENCODE_CMD")); + assert_eq!( + cli.resume_args.as_ref().unwrap(), + &["--session", "{{sessionId}}"] + ); + assert_eq!( + cli.create_session_args.as_ref().unwrap(), + &["--session-id", "{{sessionId}}"] + ); + assert_eq!(cli.model_args.as_ref().unwrap(), &["--model", "{{model}}"]); + assert_eq!(cli.permission_mode_values.as_ref().unwrap()["plan"], "{}"); + assert_eq!(cli.supports_sandbox, Some(false)); // explicit false preserved + // input had args → no default injection anywhere else: + let out = manifest.to_zod_output_value(); + assert_eq!(out["cli"]["supportsSandbox"], serde_json::json!(false)); + assert!(out["cli"].get("serverRunning").is_none()); +} + +/// FieldType::as_str doubles as the JS typeof name — the coupling the +/// content-schema refine relies on. Pin it so a future rename can't +/// silently break the typeof comparison. +#[test] +fn field_type_as_str_matches_js_typeof_names() { + assert_eq!(FieldType::String.as_str(), "string"); + assert_eq!(FieldType::Number.as_str(), "number"); + assert_eq!(FieldType::Boolean.as_str(), "boolean"); +} diff --git a/crates/freshell-extensions/tests/oracle.rs b/crates/freshell-extensions/tests/oracle.rs new file mode 100644 index 000000000..c61339c5e --- /dev/null +++ b/crates/freshell-extensions/tests/oracle.rs @@ -0,0 +1,138 @@ +//! Differential oracle conformance test (df1 EXT-01). +//! +//! Iterates `fixtures/manifest-oracle.json` — generated from the UNMODIFIED +//! legacy zod-4.3.6 schema by `port/contract/generate-manifest-oracle.ts` — +//! and asserts, for every case: +//! * same verdict class (valid / invalid-manifest / invalid-JSON-text) +//! * on success: the typed manifest re-serializes to EXACTLY zod's output +//! value (defaults materialized; order-insensitive map equality, vector +//! order strict) +//! * on schema failure: the flattened (code, path, message) issue list +//! matches byte-for-byte IN ORDER +//! +//! NEVER patch this test's expectations or the fixture to match the crate. +//! The legacy schema is the oracle; fix the crate (or regenerate the fixture +//! from the legacy schema after a deliberate legacy change / zod bump). + +use freshell_extensions::{parse_manifest, ManifestError}; + +const FIXTURE: &str = include_str!("../fixtures/manifest-oracle.json"); + +/// JSON equality AS A JS CLIENT SEES IT: numbers compare by their f64 value +/// (a JS client parses `12345678901234567000` and `1.2345678901234567e19` to +/// the same double), arrays compare order-strict, objects order-insensitive. +/// (`serde_json::Number`'s `PartialEq` is variant-strict — u64 vs f64 — which +/// would false-negative on legitimately equal doubles.) +fn js_value_eq(a: &serde_json::Value, b: &serde_json::Value) -> bool { + use serde_json::Value; + match (a, b) { + (Value::Number(x), Value::Number(y)) => x.as_f64() == y.as_f64(), + (Value::Array(x), Value::Array(y)) => { + x.len() == y.len() && x.iter().zip(y).all(|(u, v)| js_value_eq(u, v)) + } + (Value::Object(x), Value::Object(y)) => { + x.len() == y.len() + && x.iter() + .all(|(k, v)| y.get(k).is_some_and(|w| js_value_eq(v, w))) + } + _ => a == b, + } +} + +#[test] +fn oracle_conformance() { + let fixture: serde_json::Value = serde_json::from_str(FIXTURE).expect("oracle fixture parses"); + let meta = &fixture["meta"]; + assert_eq!( + meta["schemaSource"].as_str().unwrap(), + "server/extension-manifest.ts (UNMODIFIED legacy zod schema)" + ); + // Exact-version pin: the fixture is only meaningful when generated by the + // LOCK-PINNED zod. The generator hard-refuses on a drifted node_modules; + // this assert is the crate-side tripwire (update together with the lock + // pin when deliberately bumping zod). + assert_eq!( + meta["zodVersion"].as_str().unwrap(), + "4.3.6", + "fixture must derive from the package-lock-pinned zod, got {}", + meta["zodVersion"] + ); + + let cases = fixture["cases"].as_array().expect("cases array"); + assert!( + cases.len() >= 100, + "fixture should carry >=100 cases (truncation guard), got {}", + cases.len() + ); + + let mut names = std::collections::HashSet::new(); + let mut valid = 0usize; + let mut invalid = 0usize; + let mut parse_error = 0usize; + + for case in cases { + let name = case["name"].as_str().expect("case name"); + assert!(names.insert(name.to_string()), "duplicate case name {name}"); + let raw_text = case["rawText"].as_str().expect("rawText"); + let expected = &case["expected"]; + + let result = parse_manifest(raw_text); + + if expected["parseError"].as_bool().unwrap_or(false) { + parse_error += 1; + match &result { + Err(ManifestError::InvalidJson(_)) => {} + Err(ManifestError::Invalid(issues)) => { + panic!("case {name}: expected InvalidJson class, got issues {issues:?}") + } + Ok(m) => panic!("case {name}: expected InvalidJson class, got valid {m:?}"), + } + continue; + } + + if expected["success"].as_bool().unwrap() { + valid += 1; + match result { + Ok(manifest) => { + let got = manifest.to_zod_output_value(); + let want = &expected["data"]; + assert!( + js_value_eq(&got, want), + "case {name}: zod-output mismatch.\n got: {}\nwant: {}", + serde_json::to_string_pretty(&got).unwrap(), + serde_json::to_string_pretty(want).unwrap() + ); + } + Err(e) => panic!("case {name}: expected VALID, got {e}"), + } + } else { + invalid += 1; + match result { + Err(ManifestError::Invalid(issues)) => { + let got = serde_json::to_value(&issues).unwrap(); + let want = &expected["issues"]; + assert_eq!( + got, + *want, + "case {name}: issue-list mismatch.\n got: {}\nwant: {}", + serde_json::to_string_pretty(&got).unwrap(), + serde_json::to_string_pretty(want).unwrap() + ); + } + Err(ManifestError::InvalidJson(e)) => { + panic!("case {name}: expected schema-invalid, got JSON error: {e}") + } + Ok(m) => panic!("case {name}: expected schema-invalid, got valid {m:?}"), + } + } + } + + // Sanity spread so a degenerate fixture can't pass vacuously. + assert!(valid >= 35, "expected plenty of valid cases, got {valid}"); + assert!( + invalid >= 60, + "expected plenty of invalid cases, got {invalid}" + ); + assert!(parse_error >= 1, "expected at least one parse-error case"); + eprintln!("oracle conformance: {valid} valid / {invalid} invalid / {parse_error} parse-error cases ALL MATCH"); +} diff --git a/crates/freshell-freshagent/Cargo.toml b/crates/freshell-freshagent/Cargo.toml index 571124501..b2c5a1515 100644 --- a/crates/freshell-freshagent/Cargo.toml +++ b/crates/freshell-freshagent/Cargo.toml @@ -58,6 +58,7 @@ freshell-sessions = { path = "../freshell-sessions" } freshell-platform = { path = "../freshell-platform" } # The REST router + JSON extraction. axum = "0.8" +serde = { workspace = true } serde_json = { workspace = true } # ``/`` balanced-tag segmentation for opencode assistant text # (`itemsFromAssistantTextPart`/`normalizeBalancedThinkTags`, normalize.ts:100-189) needs a diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index 992726dbe..b44813414 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -1138,6 +1138,7 @@ impl FreshCodexState { // DIAG-01: the turn was accepted by the sidecar -- session_id + turn // id only, never the submitted text/prompt. tracing::info!( + provider = PROVIDER, session_id = %session_id, turn = %submitted_turn_id, "freshagent.send.accepted" @@ -1781,7 +1782,7 @@ impl FreshCodexState { // DIAG-01: crash recovery took the resume-first path -- the durable // session_id is unchanged, conversation memory survives. - tracing::info!(session_id = %session_id, "freshagent.crash_recovery.resumed_same_thread"); + tracing::info!(provider = PROVIDER, session_id = %session_id, "freshagent.crash_recovery.resumed_same_thread"); Ok(EnsureAliveOutcome::Recovered) } @@ -1910,8 +1911,13 @@ impl FreshCodexState { // DIAG-01: crash recovery had to mint a fresh thread -- the durable // identity MOVED (old_session_id -> new_thread_id); conversation // memory for the old thread is lost. `warn`, unlike the resume-first - // path, because this is the degraded fallback. + // path, because this is the degraded fallback. Carries the canonical + // `session_id` (= the NEW, now-current thread) alongside the + // recovery-forensics old/new pair, so generic session-lifecycle + // parses see one uniform identity field on every lifecycle event. tracing::warn!( + provider = PROVIDER, + session_id = %new_thread_id, old_session_id = %old_session_id, new_session_id = %new_thread_id, "freshagent.crash_recovery.minted_new" @@ -2050,7 +2056,11 @@ impl FreshCodexState { } } - tracing::info!(pid = child.id().unwrap_or(0), "freshagent.sidecar.spawned"); + tracing::info!( + provider = PROVIDER, + pid = child.id().unwrap_or(0), + "freshagent.sidecar.spawned" + ); Ok((client, notifs, ownership_id, child)) } @@ -2097,7 +2107,7 @@ impl FreshCodexState { // DIAG-01: the positive turn-complete chime only -- session_id // alone, never the turn's text/response content. if let CodexAdapterEvent::TurnComplete { session_id, .. } = &event { - tracing::info!(session_id = %session_id, "freshagent.turn.complete"); + tracing::info!(provider = PROVIDER, session_id = %session_id, "freshagent.turn.complete"); } let frame = adapter_event_to_frame(&event, &thread_id); if let Some(frame) = frame { @@ -3284,7 +3294,7 @@ fn spawn_exit_watcher( reap_owned_codex_sidecars(&ownership_id); // Task 12: the bound session is gone -- reopen its durable id. leases.clear_binding(PROVIDER, &thread_id); - tracing::info!(session_id = %thread_id, "freshagent.sidecar.reaped"); + tracing::info!(provider = PROVIDER, session_id = %thread_id, "freshagent.sidecar.reaped"); } _ = child.wait() => { reap_owned_codex_sidecars(&ownership_id); @@ -3292,11 +3302,11 @@ fn spawn_exit_watcher( // durable id (the entry stays mapped for PR-4 lazy respawn, which // re-claims through the attach/send seams). leases.clear_binding(PROVIDER, &thread_id); - tracing::info!(session_id = %thread_id, "freshagent.sidecar.reaped"); + tracing::info!(provider = PROVIDER, session_id = %thread_id, "freshagent.sidecar.reaped"); // DIAG-01: an UNREQUESTED exit -- the crash/disconnect self-heal // edge (`kill_rx` firing instead would mean a requested kill, // handled in the sibling arm above with no event here). - tracing::warn!(session_id = %thread_id, "freshagent.session.crash_detected"); + tracing::warn!(provider = PROVIDER, session_id = %thread_id, "freshagent.session.crash_detected"); // PR-4: flip the lazy-restart flag BEFORE broadcasting, so a client that // reacts to the `exited` status by immediately sending/attaching never // races ahead of `ensure_session_alive` observing a stale `false`. @@ -3847,6 +3857,14 @@ pub(crate) mod tests { crash.fields.get("session_id").map(String::as_str), Some(thread_id.as_str()) ); + // DIAG-01 schema completeness (review round 4): the crash lifecycle + // event must carry the same provider/session identity the schema + // documents for every fresh-agent session lifecycle event. + assert_eq!( + crash.fields.get("provider").map(String::as_str), + Some("codex"), + "crash_detected must carry provider per the canonical schema" + ); let spawned = capture .untagged_events_since_start() @@ -3857,6 +3875,26 @@ pub(crate) mod tests { spawned >= 1, "expected at least one freshagent.sidecar.spawned event" ); + // DIAG-01: every spawned event carries provider + the spawned pid. + for e in capture + .untagged_events_since_start() + .iter() + .filter(|e| e.message == "freshagent.sidecar.spawned") + { + assert_eq!( + e.fields.get("provider").map(String::as_str), + Some("codex"), + "sidecar.spawned must carry provider" + ); + assert!( + e.fields + .get("pid") + .and_then(|p| p.parse::().ok()) + .unwrap_or(0) + > 0, + "sidecar.spawned must carry the spawned process pid" + ); + } } fn state() -> FreshCodexState { diff --git a/crates/freshell-freshagent/src/opencode_ws.rs b/crates/freshell-freshagent/src/opencode_ws.rs index bf0efe94b..b261f310d 100644 --- a/crates/freshell-freshagent/src/opencode_ws.rs +++ b/crates/freshell-freshagent/src/opencode_ws.rs @@ -221,6 +221,13 @@ impl FreshOpencodeState { self.terminal_liveness = probe; } + /// The shared `FreshAgentState` this slice wraps. Surfaced for AUTO-01: + /// the freshell-ws client-message dispatch feeds this state's + /// [`crate::layout_store::LayoutStore`] from `ui.layout.sync` frames. + pub fn fresh_agent(&self) -> &FreshAgentState { + &self.fresh_agent + } + /// Replace the default lease map with the ONE server-wide shared map (Task 13; /// called by `main.rs` before this state is cloned into the router). pub fn set_session_leases( diff --git a/crates/freshell-platform/src/clock.rs b/crates/freshell-platform/src/clock.rs new file mode 100644 index 000000000..7aae274fc --- /dev/null +++ b/crates/freshell-platform/src/clock.rs @@ -0,0 +1,496 @@ +//! HARNESS-14 — the shared controllable test clock. +//! +//! One optional process-wide epoch-milliseconds clock, env-gated by +//! `FRESHELL_TEST_CLOCK` (`1`/`true`). When the gate is OFF — every normal +//! build and run — [`now_ms`] is a dead passthrough to `SystemTime::now()` +//! and every control function returns [`ClockError::Disabled`]; no behavior +//! change, no control surface (the REST endpoints that drive this module are +//! only mounted under the same gate — see `freshell-server`'s +//! `test_clock_router` and the legacy `server/test-clock.ts` port, whose +//! semantics this module mirrors exactly). +//! +//! ## Semantics (identical in both server implementations) +//! +//! State is `{ offset_ms, frozen_at }`. Effective time is `frozen_at` when +//! frozen, `real_now + offset_ms` when live. The control verbs: +//! +//! * [`advance_ms`] — advance-only (`0 <= ms <= MAX_ADVANCE_MS`). Frozen adds +//! to the held value; live adds to the offset. Advance-only guarantees the +//! clock is **monotonic** — every consumer computes +//! `now.saturating_sub(stamp)`, and a backward jump would wedge idle/TTL +//! math. There is deliberately no arbitrary `set` verb. +//! * [`freeze`] — capture the current effective time (idempotent). +//! * [`resume`] — continue LIVE time forward FROM the held value +//! (`offset = held - real_now`), so unfreezing produces no catch-up jump. +//! * [`reset`] — clear offset + unfrozen: pure wall clock again. +//! +//! ## Why the seams route through one clock +//! +//! Idle cleanup, rate windows, tab/device TTLs, and retention all derive +//! from epoch-ms stamps recorded earlier; sharing one clock lets a spec +//! advance past ALL of their thresholds in a single step with no wall-clock +//! sleep (see `docs/plans/df1/HARNESS-14.md` for the seam inventory). + +use std::sync::atomic::{AtomicI8, Ordering}; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// The gate env var. Trimmed/lowercased; enabled on `1` or `true`. +pub const TEST_CLOCK_ENV: &str = "FRESHELL_TEST_CLOCK"; + +/// Upper bound for one [`advance_ms`] call (31 days). Bounds runaway test +/// bugs while comfortably covering every threshold in the codebase (the +/// largest is the 24h agent idle hard cap). +pub const MAX_ADVANCE_MS: i64 = 31 * 24 * 60 * 60 * 1000; + +/// Why a control verb failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClockError { + /// `FRESHELL_TEST_CLOCK` was not set at boot (or a test override forced + /// disabled): the clock is inert and control verbs must not take effect. + Disabled, + /// [`advance_ms`] input outside `0..=MAX_ADVANCE_MS`. + InvalidAdvance, +} + +/// Live vs frozen, surfaced by [`ClockSnapshot::mode`] and the REST state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClockMode { + Live, + Frozen, +} + +impl ClockMode { + /// The exact `mode` string the REST surface emits (parity with + /// `server/test-clock.ts`). + pub fn as_str(self) -> &'static str { + match self { + ClockMode::Live => "live", + ClockMode::Frozen => "frozen", + } + } +} + +/// A point-in-time read of the clock (REST state payload shape). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClockSnapshot { + pub enabled: bool, + pub mode: ClockMode, + /// Effective epoch milliseconds right now. + pub now_ms: i64, + /// Current live-mode offset from wall clock (ms). Present-tense even + /// while frozen so `resume` math is observable. + pub offset_ms: i64, +} + +/// The pure transition core (no env, no statics) — the testable heart. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ClockCore { + offset_ms: i64, + frozen_at: Option, +} + +impl ClockCore { + const ZERO: Self = Self { + offset_ms: 0, + frozen_at: None, + }; + + fn effective_now(&self, real_now_ms: i64) -> i64 { + match self.frozen_at { + Some(held) => held, + None => real_now_ms.saturating_add(self.offset_ms), + } + } + + fn advance(&mut self, ms: i64) { + match self.frozen_at { + Some(held) => self.frozen_at = Some(held.saturating_add(ms)), + None => self.offset_ms = self.offset_ms.saturating_add(ms), + } + } + + /// Idempotent: re-freezing while frozen never moves the held value. + fn freeze(&mut self, real_now_ms: i64) { + if self.frozen_at.is_none() { + self.frozen_at = Some(self.effective_now(real_now_ms)); + } + } + + /// Continue live from the held value (monotonic: the instant after + /// resume, effective time equals the value held at freeze). + fn resume(&mut self, real_now_ms: i64) { + if let Some(held) = self.frozen_at.take() { + self.offset_ms = held.saturating_sub(real_now_ms); + } + } + + fn reset(&mut self) { + *self = Self::ZERO; + } +} + +/// Process-wide state. A single Mutex over the tiny core (not paired +/// atomics) so `freeze`/`resume` read-modify-write cycles stay atomic +/// against concurrent control verbs; `now_ms` short-circuits before the +/// lock on the gate-off fast path, so production pays nothing. +static CORE: Mutex = Mutex::new(ClockCore::ZERO); + +/// Gate cache: read from the environment ONCE (a server boot either has the +/// test clock or does not; mid-run flips via env mutation are not a +/// supported mode). +static ENV_ENABLED: OnceLock = OnceLock::new(); + +/// Test override tri-state (-1 = unset, 0 = forced off, 1 = forced on). +/// Lets in-crate tests exercise the enabled path despite the once-only env +/// read, and lets cross-crate callers (e.g. the freshell-server router +/// tests) opt in via [`set_enabled_override_for_tests`]. +static ENABLED_OVERRIDE: AtomicI8 = AtomicI8::new(-1); + +fn env_enabled() -> bool { + *ENV_ENABLED.get_or_init(|| { + std::env::var(TEST_CLOCK_ENV) + .map(|v| { + let v = v.trim().to_ascii_lowercase(); + v == "1" || v == "true" + }) + .unwrap_or(false) + }) +} + +/// Whether the test clock is active in this process. +pub fn enabled() -> bool { + match ENABLED_OVERRIDE.load(Ordering::SeqCst) { + -1 => env_enabled(), + 0 => false, + _ => true, + } +} + +/// `#[doc(hidden)]` test seam — installs (`Some`) or clears (`None`) a +/// process-wide override of the env gate. Never called by production code +/// paths; the REST surface is mounted only under `enabled()` already. +#[doc(hidden)] +pub fn set_enabled_override_for_tests(value: Option) { + ENABLED_OVERRIDE.store( + match value { + None => -1, + Some(false) => 0, + Some(true) => 1, + }, + Ordering::SeqCst, + ); +} + +/// Wall-clock epoch milliseconds (the gate-off fast path AND the live-mode +/// base). `unwrap_or(0)` mirrors every other `Date.now()` port in the repo. +fn system_now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +/// Effective epoch milliseconds. Gate OFF: identical to `system_now_ms()` +/// with zero lock/atomic traffic. Gate ON: the offset/frozen value. +pub fn now_ms() -> i64 { + if !enabled() { + return system_now_ms(); + } + let real = system_now_ms(); + CORE.lock() + .expect("test clock poisoned") + .effective_now(real) +} + +/// Current clock state. The gate-off answer is deliberately INERT (live, +/// zero offset, wall-clock now) so disabled-state callers can never observe +/// leftover virtual state. +pub fn snapshot() -> ClockSnapshot { + let real = system_now_ms(); + let core = *CORE.lock().expect("test clock poisoned"); + if !enabled() { + return ClockSnapshot { + enabled: false, + mode: ClockMode::Live, + now_ms: real, + offset_ms: 0, + }; + } + ClockSnapshot { + enabled: true, + mode: if core.frozen_at.is_some() { + ClockMode::Frozen + } else { + ClockMode::Live + }, + now_ms: core.effective_now(real), + offset_ms: core.offset_ms, + } +} + +/// Drive a control verb, gating + validating uniformly. `f` receives the +/// core and the real now; `validate` runs before any mutation. +fn drive(f: impl FnOnce(&mut ClockCore, i64)) -> Result { + if !enabled() { + return Err(ClockError::Disabled); + } + let real = system_now_ms(); + { + let mut core = CORE.lock().expect("test clock poisoned"); + f(&mut core, real); + } + Ok(snapshot()) +} + +/// Advance effective time by `ms` (frozen: steps the held value; live: adds +/// to the offset). See the module docs for the advance-only/monotonic rule. +pub fn advance_ms(ms: i64) -> Result { + if !(0..=MAX_ADVANCE_MS).contains(&ms) { + return Err(ClockError::InvalidAdvance); + } + drive(|core, _real| core.advance(ms)) +} + +/// Hold effective time at its current value until [`resume`] (idempotent). +pub fn freeze() -> Result { + drive(|core, real| core.freeze(real)) +} + +/// Resume live time continuing from the held value (no catch-up jump). +pub fn resume() -> Result { + drive(|core, real| core.resume(real)) +} + +/// Back to pure wall clock (offset 0, live). +pub fn reset() -> Result { + if !enabled() { + return Err(ClockError::Disabled); + } + CORE.lock().expect("test clock poisoned").reset(); + Ok(snapshot()) +} + +#[cfg(test)] +mod tests { + //! RED-first for HARNESS-14 (T1). The enabled-path tests share the + //! process-global core, so every one takes `GATE_TEST_LOCK` and resets + + //! clears the override on exit (guard) — a poisoned leak would turn + //! other tests' `now_ms` virtual. + use super::*; + + static GATE_TEST_LOCK: Mutex<()> = Mutex::new(()); + + struct OverrideGuard; + impl OverrideGuard { + /// Serialize against every other override-using test and install the + /// requested gate state. Poison-tolerant (`into_inner`) so one + /// panicking sibling cannot cascade the whole clock suite red. + fn locked(enabled_state: bool) -> (std::sync::MutexGuard<'static, ()>, Self) { + let guard = GATE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + set_enabled_override_for_tests(Some(enabled_state)); + if enabled_state { + reset().expect("override just enabled; reset must succeed"); + } + (guard, Self) + } + } + impl Drop for OverrideGuard { + fn drop(&mut self) { + let _ = reset(); + set_enabled_override_for_tests(None); + } + } + + // ── gate-off identity ──────────────────────────────────────────────── + + #[test] + fn gate_off_now_ms_is_identity_and_controls_are_disabled() { + // Forced-DISABLED under the same lock (the default env-unset path is + // what production always runs; the `env_enabled` half is pinned + // separately below without touching shared state). + let (_lock, _guard) = OverrideGuard::locked(false); + let before = system_now_ms(); + let t = now_ms(); + let after = system_now_ms(); + assert!(before <= t && t <= after, "now_ms must equal wall clock"); + + assert_eq!(advance_ms(1000), Err(ClockError::Disabled)); + assert_eq!(freeze(), Err(ClockError::Disabled)); + assert_eq!(resume(), Err(ClockError::Disabled)); + assert_eq!(reset(), Err(ClockError::Disabled)); + + let snap = snapshot(); + assert!(!snap.enabled); + assert_eq!(snap.mode, ClockMode::Live); + assert_eq!(snap.offset_ms, 0); + assert!(before <= snap.now_ms && snap.now_ms <= system_now_ms()); + } + + #[test] + fn gate_off_default_env_is_disabled() { + // The env var is absent in the test environment unless a developer + // exported it; with no override ever installed, `enabled()` must be + // false (this also pins that mere PRESENCE of a wrong value like + // `0`/`yes` does not enable). + if std::env::var(TEST_CLOCK_ENV).is_ok() { + eprintln!("{TEST_CLOCK_ENV} set in environment; skipping"); + return; + } + assert!(!env_enabled()); + } + + // ── enabled-path transitions ───────────────────────────────────────── + + #[test] + fn advance_moves_live_time_forward_by_exactly_the_delta() { + let (_lock, _guard) = OverrideGuard::locked(true); + let before = snapshot(); + advance_ms(90_000).unwrap(); + let after = snapshot(); + assert_eq!(after.now_ms - before.now_ms, 90_000); + assert_eq!(after.offset_ms, 90_000); + assert_eq!(after.mode, ClockMode::Live); + } + + #[test] + fn freeze_holds_time_constant_and_advance_steps_the_held_value() { + let (_lock, _guard) = OverrideGuard::locked(true); + advance_ms(60_000).unwrap(); + let frozen = freeze().unwrap(); + assert_eq!(frozen.mode, ClockMode::Frozen); + // Frozen: consecutive reads do not move even though real time does. + std::thread::sleep(std::time::Duration::from_millis(20)); + assert_eq!(snapshot().now_ms, frozen.now_ms); + + // Advancing while frozen steps the held value EXACTLY (and two + // steps compose: T0+5 then +11 lands on T0+16). + let stepped = advance_ms(5 * 60_000).unwrap(); + assert_eq!(stepped.now_ms, frozen.now_ms + 5 * 60_000); + let stepped2 = advance_ms(11 * 60_000).unwrap(); + assert_eq!(stepped2.now_ms, frozen.now_ms + 16 * 60_000); + assert_eq!(stepped2.mode, ClockMode::Frozen); + } + + #[test] + fn freeze_is_idempotent() { + let (_lock, _guard) = OverrideGuard::locked(true); + let f1 = freeze().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(5)); + let f2 = freeze().unwrap(); + assert_eq!(f1.now_ms, f2.now_ms, "re-freeze must not re-capture"); + } + + #[test] + fn resume_continues_from_the_held_value_without_a_jump() { + let (_lock, _guard) = OverrideGuard::locked(true); + advance_ms(120_000).unwrap(); + let frozen = freeze().unwrap(); + let resumed = resume().unwrap(); + assert_eq!(resumed.mode, ClockMode::Live); + // The instant after resume, effective time ≈ the held value: no + // catch-up jump back to wall clock (which would be a ~120s + // BACKWARD move) and no leap forward. + let drift = (resumed.now_ms - frozen.now_ms).abs(); + assert!(drift < 1_000, "resume jumped by {drift}ms"); + // And from there it tracks real time again. + std::thread::sleep(std::time::Duration::from_millis(20)); + let later = snapshot(); + assert!(later.now_ms >= resumed.now_ms, "live clock must advance"); + assert!( + later.now_ms - resumed.now_ms < 1_000, + "live clock must advance by REAL elapsed time, not retroactively" + ); + } + + #[test] + fn reset_restores_pure_wall_clock() { + let (_lock, _guard) = OverrideGuard::locked(true); + advance_ms(600_000).unwrap(); + freeze().unwrap(); + let snap = reset().unwrap(); + assert_eq!(snap.mode, ClockMode::Live); + assert_eq!(snap.offset_ms, 0); + let real = system_now_ms(); + assert!( + (snap.now_ms - real).abs() < 1_000, + "after reset, now_ms ({}) must equal wall clock ({real})", + snap.now_ms + ); + } + + #[test] + fn monotonic_across_every_verb() { + let (_lock, _guard) = OverrideGuard::locked(true); + let mut last = snapshot().now_ms; + let mut check = |snap: ClockSnapshot| { + assert!(snap.now_ms >= last, "clock went backwards"); + last = snap.now_ms; + }; + check(advance_ms(1).unwrap()); + check(freeze().unwrap()); + check(advance_ms(1000 * 60 * 60).unwrap()); + check(resume().unwrap()); + check(advance_ms(0).unwrap()); + // `reset()` is deliberately NOT in this chain: returning to pure wall + // clock UNDOES the accumulated offset, which is a backward step by + // design (it exists so specs can restore a pristine clock). Its + // back-to-wall behavior is pinned by `reset_restores_pure_wall_clock`. + } + + // ── validation ─────────────────────────────────────────────────────── + + #[test] + fn advance_rejects_out_of_range_inputs_without_mutating() { + let (_lock, _guard) = OverrideGuard::locked(true); + let before = snapshot(); + assert_eq!(advance_ms(-1), Err(ClockError::InvalidAdvance)); + assert_eq!( + advance_ms(MAX_ADVANCE_MS + 1), + Err(ClockError::InvalidAdvance) + ); + assert_eq!(advance_ms(i64::MAX), Err(ClockError::InvalidAdvance)); + let after = snapshot(); + // A rejected advance must not drift the clock (modulo real elapsed). + assert!((after.now_ms - before.now_ms).abs() < 1_000); + // ^ 31 days exactly is IN range (boundary is inclusive). Asserted + // on a FROZEN clock: the whole-snapshot equality below compares the + // snapshot `advance_ms` captured with a fresh `snapshot()`, and on + // the live path each sample calls `system_now_ms()` separately — + // a real-time ms tick between them would flake the equality. + // Frozen makes `now_ms` a pure function of core state, so the + // boundary probe is deterministic. (Input validation runs before + // `drive` on both paths, so the frozen advance still pins + // boundary inclusiveness.) + freeze().unwrap(); + assert_eq!(advance_ms(MAX_ADVANCE_MS), Ok(snapshot())); + } + + #[test] + fn disabled_clock_control_verbs_do_not_mutate_state() { + // Even with state left over in the core, disabling turns every verb + // into a Disabled no-op and `now_ms` back into wall time. + let (_lock, _guard) = OverrideGuard::locked(true); + advance_ms(60_000).unwrap(); + set_enabled_override_for_tests(Some(false)); + assert!(!enabled()); + assert_eq!(advance_ms(1000), Err(ClockError::Disabled)); + let snap = snapshot(); + assert!(!snap.enabled); + let real = system_now_ms(); + assert!((snap.now_ms - real).abs() < 1_000); + // Re-enable: the stale offset must still be there (reset is the + // ONLY way to clear) — no hidden clearing on the gate edge. + set_enabled_override_for_tests(Some(true)); + let snap2 = snapshot(); + assert!(snap2.offset_ms >= 60_000); + } + + #[test] + fn snapshot_mode_strings_match_the_rest_surface() { + assert_eq!(ClockMode::Live.as_str(), "live"); + assert_eq!(ClockMode::Frozen.as_str(), "frozen"); + } +} diff --git a/crates/freshell-platform/src/lib.rs b/crates/freshell-platform/src/lib.rs index 575e5e14d..528ae3cbc 100644 --- a/crates/freshell-platform/src/lib.rs +++ b/crates/freshell-platform/src/lib.rs @@ -52,6 +52,7 @@ //! wrappers at the edges perform the real reads and delegate to the pure core. pub mod cli_launch; +pub mod clock; pub mod detect; pub mod git_meta; pub mod mcp_inject; diff --git a/crates/freshell-server/Cargo.toml b/crates/freshell-server/Cargo.toml index 12b1024a1..3928af1b6 100644 --- a/crates/freshell-server/Cargo.toml +++ b/crates/freshell-server/Cargo.toml @@ -22,6 +22,9 @@ path = "src/main.rs" freshell-protocol = { path = "../freshell-protocol" } freshell-ws = { path = "../freshell-ws" } freshell-api = { path = "../freshell-api" } +# The STRICT extension-manifest schema (df1 EXT-01) — the scan path validates +# every freshell.json against it (lenient subset removed). +freshell-extensions = { path = "../freshell-extensions" } # The fresh-agent REST surface (opencode slice) wired for the oracle T2 rung. freshell-freshagent = { path = "../freshell-freshagent" } # DEV-0006 S4 inc.2: the codex managed-launch lifecycle manager — main.rs calls its diff --git a/crates/freshell-server/src/boot.rs b/crates/freshell-server/src/boot.rs index f3e0dd617..d2d767ff4 100644 --- a/crates/freshell-server/src/boot.rs +++ b/crates/freshell-server/src/boot.rs @@ -102,27 +102,65 @@ async fn bootstrap(State(state): State, headers: HeaderMap) -> Respon return unauthorized(); } let settings = state.settings.get().await; - // `shell`: `server/index.ts:191` wires `getShellTaskStatus` to - // `startupState.snapshot().tasks`; the original registers exactly two - // startup tasks (`sessionRepairService` @ index.ts:886, `codingCliIndexer` - // @ index.ts:901 — key order as observed live) and `ready` is - // `Object.values(tasks).every(Boolean)`. The port performs its equivalent - // init before binding the listener, so the steady-state snapshot (all - // true) is the faithful response for every observable request. - // `perf`: `getPerfLogging` (`index.ts:192`) → `{ logging: perfConfig.enabled }`, - // where enabled = parseBoolean(PERF_LOGGING) || parseBoolean(PERF_DEBUG) - // (`server/perf-logger.ts:33-35`). - Json(json!({ - "settings": settings, - "platform": &*state.platform, - "shell": { + // CFG-04: the boot-extracted legacy local-settings seed rides the + // bootstrap payload (bootstrap-only, mirroring + // `server/shell-bootstrap-router.ts:34-36,75` — it appears here and + // nowhere else: not in `/api/settings`, not in any WS frame). + let legacy_local_settings_seed = state.settings.legacy_local_settings_seed(); + Json(bootstrap_payload( + &settings, + legacy_local_settings_seed, + &state.platform, + )) + .into_response() +} + +/// The bootstrap payload assembly, extracted as a pure function so the +/// seed-carrying contract is unit-testable without a live `BootState`. +/// +/// `shell`: `server/index.ts:191` wires `getShellTaskStatus` to +/// `startupState.snapshot().tasks`; the original registers exactly two +/// startup tasks (`sessionRepairService` @ index.ts:886, `codingCliIndexer` +/// @ index.ts:901 — key order as observed live) and `ready` is +/// `Object.values(tasks).every(Boolean)`. The port performs its equivalent +/// init before binding the listener, so the steady-state snapshot (all +/// true) is the faithful response for every observable request. +/// `perf`: `getPerfLogging` (`index.ts:192`) → `{ logging: perfConfig.enabled }`, +/// where enabled = parseBoolean(PERF_LOGGING) || parseBoolean(PERF_DEBUG) +/// (`server/perf-logger.ts:33-35`). +/// +/// Key order mirrors the original's payload literal +/// (`shell-bootstrap-router.ts:73-80`): `settings`, then +/// `legacyLocalSettingsSeed` — present ONLY when a seed exists (the +/// original's conditional spread `...(seed ? { legacyLocalSettingsSeed } : {})`; +/// absent, never `null`) — then `platform`, `shell`, `perf`. +fn bootstrap_payload( + settings: &freshell_protocol::ServerSettings, + legacy_local_settings_seed: Option, + platform: &Value, +) -> Value { + let mut payload = serde_json::Map::new(); + payload.insert( + "settings".to_string(), + serde_json::to_value(settings).unwrap_or_else(|_| json!({})), + ); + if let Some(seed) = legacy_local_settings_seed { + payload.insert("legacyLocalSettingsSeed".to_string(), seed); + } + payload.insert("platform".to_string(), platform.clone()); + payload.insert( + "shell".to_string(), + json!({ "authenticated": true, "ready": true, "tasks": { "sessionRepairService": true, "codingCliIndexer": true }, - }, - "perf": { "logging": perf_logging_enabled() }, - })) - .into_response() + }), + ); + payload.insert( + "perf".to_string(), + json!({ "logging": perf_logging_enabled() }), + ); + Value::Object(payload) } /// `parseBoolean(env.PERF_LOGGING) || parseBoolean(env.PERF_DEBUG)` @@ -889,4 +927,73 @@ mod tests { // Would panic on an overlapping-method conflict; GET+PATCH is allowed. let _merged: Router = boot.merge(other); } + + // ── CFG-04: bootstrap carries the legacyLocalSettingsSeed ────────────── + + /// The seed rides the bootstrap payload when (and only when) one was + /// extracted at boot — `server/shell-bootstrap-router.ts:75`'s + /// `...(legacyLocalSettingsSeed ? { legacyLocalSettingsSeed } : {})`, in + /// the original's key order (settings, seed, platform, shell, perf). + #[test] + fn bootstrap_payload_includes_seed_in_legacy_key_order() { + let settings = crate::settings::default_server_settings(); + let seed = json!({ + "theme": "light", + "sidebar": { "sortMode": "project" }, + "notifications": { "soundEnabled": false } + }); + let platform = json!({ "platform": "linux", "hostName": "testbox" }); + + let payload = bootstrap_payload(&settings, Some(seed.clone()), &platform); + + let keys: Vec<&str> = payload + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + keys, + vec![ + "settings", + "legacyLocalSettingsSeed", + "platform", + "shell", + "perf" + ] + ); + assert_eq!(payload["legacyLocalSettingsSeed"], seed); + assert_eq!( + payload["settings"], + serde_json::to_value(&settings).expect("serializable") + ); + assert_eq!(payload["platform"], platform); + // The pre-existing shape is untouched (handler-comment contract). + assert_eq!(payload["shell"]["authenticated"], json!(true)); + assert_eq!(payload["shell"]["ready"], json!(true)); + assert_eq!( + payload["shell"]["tasks"]["sessionRepairService"], + json!(true) + ); + assert!(payload["perf"]["logging"].is_boolean()); + } + + /// No seed extracted at boot (fresh install / already-migrated profile) + /// → the key is ABSENT from the payload — never `null` + /// (`shell-bootstrap-router.ts`'s conditional spread). + #[test] + fn bootstrap_payload_omits_seed_when_absent() { + let settings = crate::settings::default_server_settings(); + let payload = bootstrap_payload(&settings, None, &json!({ "platform": "linux" })); + assert!( + payload.get("legacyLocalSettingsSeed").is_none(), + "seed key leaked into a seedless payload: {payload}" + ); + assert!(!serde_json::to_string(&payload) + .unwrap() + .contains("legacyLocalSettingsSeed")); + // Everything else is still there. + assert!(payload.get("settings").is_some()); + assert!(payload.get("shell").is_some()); + } } diff --git a/crates/freshell-server/src/extensions.rs b/crates/freshell-server/src/extensions.rs index 0cbd543ab..c13b3ce5e 100644 --- a/crates/freshell-server/src/extensions.rs +++ b/crates/freshell-server/src/extensions.rs @@ -1,18 +1,24 @@ -//! Extension registry + coding-CLI availability detection (Follow-up 3.19). +//! Extension registry + coding-CLI availability detection (Follow-up 3.19; +//! STRICT schema port: df1 EXT-01). //! //! **FAITHFUL-PORT + unit-proven, NOT differential-oracle-proven.** There is no //! captured original transcript for these boot reads; correctness is argued by a //! faithful port with file:line citations, a response-SHAPE match to the frozen //! client contract, and the unit tests below (+ curl smokes in the report). +//! (The manifest schema itself IS differential-oracle-proven — see below.) //! //! Ports, additively (no `server/` or `shared/` source touched): //! * `server/extension-manager.ts` `scan()` (62-131) and `toClientRegistry()` //! (144-191) — discover `freshell.json` manifests under the extension dirs and //! serialize the client registry the SPA fetches at `GET /api/extensions` //! (`src/hooks/useEnsureExtensionsRegistry.ts`). -//! * `server/extension-manifest.ts` (81-103) — the manifest schema subset used by -//! the registry + CLI detection (lenient: unknown keys ignored rather than the -//! original's strict reject, since the bundled manifests are trusted). +//! * `server/extension-manifest.ts` — the manifest schema. Since EXT-01 the +//! FULL STRICT schema (strict unknown-key rejection, category↔block refine, +//! defaults, JS-safe-int timeouts, per-field capability validation) lives in +//! the `freshell-extensions` crate, pinned by a generated differential +//! oracle against the unmodified legacy zod schema +//! (`crates/freshell-extensions/fixtures/manifest-oracle.json`). This module +//! consumes it; the old lenient subset is gone. //! * `server/platform.ts` `detectAvailableClis()` (107-118), //! `DEFAULT_CLI_DETECTION_SPECS` (97-103), `isCommandAvailable()` (84-91) — run //! `which`/`where.exe` per CLI (env-var override) to populate the @@ -27,82 +33,22 @@ use std::collections::BTreeSet; use std::path::{Path, PathBuf}; +use freshell_extensions::{parse_manifest, Category, ExtensionManifest, ManifestError}; use freshell_platform::detect::{host_os_live, is_windows, HostOs}; use freshell_platform::{CommandRunner, StdCommandRunner}; -use serde::Deserialize; use serde_json::{json, Map, Value}; const MANIFEST_FILE: &str = "freshell.json"; -// ── Manifest schema (subset of server/extension-manifest.ts) ──────────────── - -/// The terminal-behavior block (`extension-manifest.ts:45-48`). -#[derive(Debug, Clone, Deserialize)] -struct TerminalBehavior { - #[serde(rename = "preferredRenderer", skip_serializing_if = "Option::is_none")] - preferred_renderer: Option, - #[serde(rename = "scrollInputPolicy", skip_serializing_if = "Option::is_none")] - scroll_input_policy: Option, -} - -/// The CLI config block (`extension-manifest.ts:50-66`). The full arg-template -/// fields (`args`/`env`/`modelArgs`/`sandboxArgs`/`permissionModeArgs`/ -/// `createSessionArgs`) are modeled since task-006: they feed the coding-CLI -/// command specs (`server/index.ts:231-255` compilation), per -/// `port/machine/specs/cli-argv-fidelity.md` §3.1. -#[derive(Debug, Clone, Deserialize)] -struct CliConfig { - command: String, - #[serde(rename = "envVar")] - env_var: Option, - args: Option>, - env: Option>, - #[serde(rename = "resumeArgs")] - resume_args: Option>, - #[serde(rename = "createSessionArgs")] - create_session_args: Option>, - #[serde(rename = "modelArgs")] - model_args: Option>, - #[serde(rename = "sandboxArgs")] - sandbox_args: Option>, - #[serde(rename = "permissionModeArgs")] - permission_mode_args: Option>, - #[serde(rename = "supportsPermissionMode")] - supports_permission_mode: Option, - #[serde(rename = "supportsModel")] - supports_model: Option, - #[serde(rename = "supportsSandbox")] - supports_sandbox: Option, - #[serde(rename = "terminalBehavior")] - terminal_behavior: Option, -} - -/// The picker config block (`extension-manifest.ts:72-75`). -#[derive(Debug, Clone, Deserialize)] -struct PickerConfig { - #[serde(skip_serializing_if = "Option::is_none")] - shortcut: Option, - #[serde(skip_serializing_if = "Option::is_none")] - group: Option, -} - -/// The top-level manifest (`extension-manifest.ts:81-103`). Lenient: unknown keys -/// are ignored (the bundled manifests are trusted; the original's strict reject is -/// a manifest-authoring guard, not a wire invariant). -#[derive(Debug, Clone, Deserialize)] -struct ExtensionManifest { - name: String, - version: String, - label: String, - description: String, - category: String, - icon: Option, - url: Option, - #[serde(rename = "contentSchema")] - content_schema: Option, - picker: Option, - cli: Option, -} +// ── Manifest schema ───────────────────────────────────────────────────────── +// +// The strict schema now lives in the `freshell-extensions` crate (df1 EXT-01): +// `freshell_extensions::ExtensionManifest` is obtainable ONLY through +// validation (`parse_manifest`), so the lenient/strict split is structurally +// impossible here. Validation failures map to the legacy scan warnings: +// `ManifestError::InvalidJson` → 'invalid JSON in manifest'; +// `ManifestError::Invalid(issues)` → 'invalid manifest' (issues are zod-parity +// (code, path, message) triples — see the crate docs). // ── Registry ───────────────────────────────────────────────────────────────── @@ -131,7 +77,10 @@ pub struct ExtensionRegistry { impl ExtensionRegistry { /// `scan(dirs)` (`extension-manager.ts:62-131`): for each dir, read `freshell.json` /// from each subdirectory, parse it, and register under `manifest.name` - /// (first-wins on duplicate). Invalid/missing manifests are skipped. + /// (first-wins on duplicate). Invalid/missing manifests are skipped WITH A + /// WARNING (`extension-manager.ts:90-111`) — the two failure classes map to + /// legacy's two log lines ('invalid JSON in manifest' / 'invalid manifest', + /// the latter carrying the zod-parity issue list). /// /// **Determinism note:** the original iterates `fs.readdirSync` order (which is /// filesystem-dependent, i.e. nondeterministic); this port sorts subdirectory @@ -158,15 +107,37 @@ impl ExtensionRegistry { for name in sub_names { let manifest_path = dir.join(&name).join(MANIFEST_FILE); - let Ok(raw) = std::fs::read_to_string(&manifest_path) else { + let Ok(bytes) = std::fs::read(&manifest_path) else { continue; }; - let Ok(manifest) = serde_json::from_str::(&raw) else { - continue; + // Legacy `fs.readFileSync(path, 'utf-8')` replaces invalid + // UTF-8 with U+FFFD rather than throwing; match that exactly so + // corrupted manifests land on the same warn/skip path (and + // U+FFFD inside a string literal parses identically both ways). + let raw = String::from_utf8_lossy(&bytes); + let manifest = match parse_manifest(&raw) { + Ok(manifest) => manifest, + Err(ManifestError::InvalidJson(err)) => { + // `extension-manager.ts:100` — 'invalid JSON in manifest' + tracing::warn!( + manifest_path = %manifest_path.display(), + error = %err, + "Extension scan: invalid JSON in manifest" + ); + continue; + } + Err(ManifestError::Invalid(issues)) => { + // `extension-manager.ts:106-109` — 'invalid manifest' with + // the issue list (legacy: `result.error.format()`; here: + // the same (code, path, message) content, flat). + tracing::warn!( + manifest_path = %manifest_path.display(), + ?issues, + "Extension scan: invalid manifest" + ); + continue; + } }; - if !is_valid_manifest(&manifest) { - continue; - } if seen.contains(&manifest.name) { continue; // duplicate name — first wins } @@ -200,7 +171,7 @@ impl ExtensionRegistry { pub fn discovered_cli_names(&self) -> Vec { self.entries .iter() - .filter(|e| e.manifest.category == "cli" && e.manifest.cli.is_some()) + .filter(|e| e.manifest.category == Category::Cli && e.manifest.cli.is_some()) .map(|e| e.manifest.name.clone()) .collect() } @@ -226,7 +197,7 @@ impl ExtensionRegistry { pub fn cli_command_specs(&self) -> Vec { self.entries .iter() - .filter(|e| e.manifest.category == "cli") + .filter(|e| e.manifest.category == Category::Cli) .filter_map(|e| e.manifest.cli.as_ref().map(|cli| (e, cli))) .map(|(e, cli)| freshell_platform::CliCommandSpec { name: e.manifest.name.clone(), @@ -235,8 +206,12 @@ impl ExtensionRegistry { // empty is falsy, so model it as `None`. env_var: cli.env_var.clone().filter(|v| !v.is_empty()), default_cmd: cli.command.clone(), - base_args: cli.args.clone().unwrap_or_default(), - base_env: cli.env.clone().unwrap_or_default(), + base_args: cli.args.clone(), + base_env: cli + .env + .clone() + .map(|m| m.into_iter().collect()) + .unwrap_or_default(), resume_args: cli.resume_args.clone(), create_session_args: cli.create_session_args.clone(), model_args: cli.model_args.clone(), @@ -249,7 +224,7 @@ impl ExtensionRegistry { pub fn cli_detection_specs(&self) -> Vec { self.entries .iter() - .filter(|e| e.manifest.category == "cli") + .filter(|e| e.manifest.category == Category::Cli) .filter_map(|e| e.manifest.cli.as_ref().map(|cli| (e, cli))) .map(|(e, cli)| CliDetectionSpec { name: e.manifest.name.clone(), @@ -261,19 +236,6 @@ impl ExtensionRegistry { } } -/// The manifest refinement (`extension-manifest.ts:96-103`): the declared category -/// must carry exactly its own config block. Only `cli` is modeled here; a `cli` -/// manifest without a `cli` block (or vice-versa) is rejected. `client`/`server` -/// manifests are accepted as-is (their blocks aren't modeled but aren't required -/// for the registry/CLI surface). -fn is_valid_manifest(m: &ExtensionManifest) -> bool { - match m.category.as_str() { - "cli" => m.cli.is_some(), - "client" | "server" => true, - _ => false, - } -} - /// Build one `ClientExtensionEntry` (`extension-manager.ts:145-190`). Optional /// fields are omitted when absent, matching `JSON.stringify`'s `undefined` elision. fn client_entry(m: &ExtensionManifest) -> Value { @@ -282,12 +244,14 @@ fn client_entry(m: &ExtensionManifest) -> Value { obj.insert("version".into(), json!(m.version)); obj.insert("label".into(), json!(m.label)); obj.insert("description".into(), json!(m.description)); - obj.insert("category".into(), json!(m.category)); + obj.insert("category".into(), json!(m.category.as_str())); // `serverRunning` is always present; this read-only port never runs server // extensions, so it is always false (and `serverPort` is omitted). obj.insert("serverRunning".into(), json!(false)); - if m.icon.is_some() { + // Legacy gates on TRUTHINESS (`if (manifest.icon)`): an empty-string icon + // is schema-valid but must not produce an iconUrl. + if m.icon.as_ref().is_some_and(|icon| !icon.is_empty()) { obj.insert( "iconUrl".into(), json!(format!( @@ -300,7 +264,12 @@ fn client_entry(m: &ExtensionManifest) -> Value { obj.insert("url".into(), json!(url)); } if let Some(cs) = &m.content_schema { - obj.insert("contentSchema".into(), cs.clone()); + // Typed content schema re-serializes exactly (validated input keys + // only, optionals elided, insertion order preserved). + obj.insert( + "contentSchema".into(), + serde_json::to_value(cs).unwrap_or(Value::Null), + ); } if let Some(p) = &m.picker { obj.insert( @@ -308,7 +277,7 @@ fn client_entry(m: &ExtensionManifest) -> Value { serde_json::to_value(p).unwrap_or(Value::Null), ); } - if m.category == "cli" { + if m.category == Category::Cli { if let Some(cli) = &m.cli { let mut c = Map::new(); if let Some(v) = cli.supports_permission_mode { @@ -339,32 +308,6 @@ fn client_entry(m: &ExtensionManifest) -> Value { Value::Object(obj) } -impl serde::Serialize for TerminalBehavior { - fn serialize(&self, s: S) -> Result { - let mut m = Map::new(); - if let Some(v) = &self.preferred_renderer { - m.insert("preferredRenderer".into(), json!(v)); - } - if let Some(v) = &self.scroll_input_policy { - m.insert("scrollInputPolicy".into(), json!(v)); - } - Value::Object(m).serialize(s) - } -} - -impl serde::Serialize for PickerConfig { - fn serialize(&self, s: S) -> Result { - let mut m = Map::new(); - if let Some(v) = &self.shortcut { - m.insert("shortcut".into(), json!(v)); - } - if let Some(v) = &self.group { - m.insert("group".into(), json!(v)); - } - Value::Object(m).serialize(s) - } -} - // ── availableClis detection ───────────────────────────────────────────────── /// `detectAvailableClis(specs)` (`platform.ts:107-118`): for each spec, resolve the @@ -504,6 +447,262 @@ mod tests { "picker": { "group": "agents" } }"#; + // ── df1 EXT-01: the scan path consumes the STRICT manifest schema ────── + // + // Legacy `extension-manager.ts` validates every `freshell.json` against + // the strict zod schema and skips-with-warning on ANY failure; the + // previous Rust port ran a lenient subset (unknown keys ignored, + // client/server manifests accepted without their config blocks). These + // tests pin the strict behavior through the real scan path. + + #[test] + fn scan_skips_manifest_with_unknown_key_but_keeps_valid_sibling() { + // Strict-mode core rule: an unknown key rejects the WHOLE manifest + // (`unrecognized_keys`), like a typo'd legacy manifest. + const BAD: &str = r#"{ + "name": "typo-ext", "version": "1.0.0", "label": "L", + "description": "D", "category": "cli", + "cli": { "command": "x" }, "clii": { "command": "y" } + }"#; + let root = tmp(); + write_manifest(&root, "claude-code", CLAUDE_MANIFEST); + write_manifest(&root, "typo-ext", BAD); + let reg = ExtensionRegistry::scan(std::slice::from_ref(&root)); + assert_eq!( + reg.discovered_cli_names(), + vec!["claude"], + "typo manifest skipped, valid sibling kept" + ); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn scan_skips_category_block_mismatch_and_missing_blocks() { + // The category refine: exactly one block, matching `category`. + const MISSING_BLOCK: &str = r#"{ + "name": "no-block", "version": "1.0.0", "label": "L", + "description": "D", "category": "cli" + }"#; + const WRONG_BLOCK: &str = r#"{ + "name": "wrong-block", "version": "1.0.0", "label": "L", + "description": "D", "category": "cli", + "server": { "command": "node" } + }"#; + let root = tmp(); + write_manifest(&root, "no-block", MISSING_BLOCK); + write_manifest(&root, "wrong-block", WRONG_BLOCK); + let reg = ExtensionRegistry::scan(std::slice::from_ref(&root)); + assert!( + reg.to_client_registry().is_empty(), + "category/block mismatches must not register" + ); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn scan_skips_client_and_server_manifests_without_their_blocks() { + // The lenient subset accepted category=client/server unconditionally; + // the strict schema requires the matching block (client.entry / + // server.command). + const CLIENT_NO_BLOCK: &str = r#"{ + "name": "client-no-block", "version": "1.0.0", "label": "L", + "description": "D", "category": "client" + }"#; + const SERVER_NO_BLOCK: &str = r#"{ + "name": "server-no-block", "version": "1.0.0", "label": "L", + "description": "D", "category": "server" + }"#; + const CLIENT_OK: &str = r#"{ + "name": "client-ok", "version": "1.0.0", "label": "L", + "description": "D", "category": "client", + "client": { "entry": "./index.html" } + }"#; + let root = tmp(); + write_manifest(&root, "client-no-block", CLIENT_NO_BLOCK); + write_manifest(&root, "server-no-block", SERVER_NO_BLOCK); + write_manifest(&root, "client-ok", CLIENT_OK); + let reg = ExtensionRegistry::scan(std::slice::from_ref(&root)); + let entries = reg.to_client_registry(); + assert_eq!(entries.len(), 1, "only the well-formed client manifest"); + assert_eq!(entries[0]["name"], json!("client-ok")); + assert_eq!(entries[0]["category"], json!("client")); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn scan_skips_invalid_json_manifest_text() { + let root = tmp(); + let bad = root.join("not-json"); + std::fs::create_dir_all(&bad).unwrap(); + std::fs::write(bad.join(MANIFEST_FILE), "{ not json").unwrap(); + write_manifest(&root, "claude-code", CLAUDE_MANIFEST); + let reg = ExtensionRegistry::scan(std::slice::from_ref(&root)); + assert_eq!(reg.discovered_cli_names(), vec!["claude"]); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn empty_string_icon_produces_no_icon_url() { + // `icon` is a bare z.string() in the legacy schema — "" is VALID — + // but the legacy registry gates iconUrl on TRUTHINESS + // (`if (manifest.icon)`), so "" must not emit one. + const EMPTY_ICON: &str = r#"{ + "name": "empty-icon", "version": "1.0.0", "label": "L", + "description": "D", "category": "cli", + "cli": { "command": "x" }, "icon": "" + }"#; + let root = tmp(); + write_manifest(&root, "empty-icon", EMPTY_ICON); + let reg = ExtensionRegistry::scan(std::slice::from_ref(&root)); + let entries = reg.to_client_registry(); + assert_eq!( + entries.len(), + 1, + "empty-icon manifest is VALID under strict" + ); + assert!( + entries[0].get("iconUrl").is_none(), + "empty icon must not produce iconUrl (legacy truthiness gate)" + ); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn all_bundled_manifests_validate_and_register_through_scan() { + // Boot-path regression: the repo's extensions/ tree must survive the + // strict schema (all six are CLI-category today). Read-only: the + // dir is only scanned, never written. + let dir = PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../extensions")); + let reg = ExtensionRegistry::scan(&[dir]); + let mut names = reg.discovered_cli_names(); + names.sort(); + assert_eq!( + names, + ["amplifier", "claude", "codex", "gemini", "kimi", "opencode"], + "every bundled extension must validate under the strict schema" + ); + } + + #[test] + fn scan_warns_with_legacys_two_log_lines_for_the_two_failure_classes() { + // EXT-01 diagnostics parity: legacy logs 'invalid JSON in manifest' + // for unparseable text and 'invalid manifest' (+ issues) for schema + // failures — warn and skip, never crash discovery. Assert BOTH lines + // actually fire (absence from the registry alone was also true of the + // old lenient port). + // + // Capture strategy (matching freshell-freshagent's documented + // investigation): a set_global_default subscriber installed EXACTLY + // ONCE per test binary via OnceLock. Thread-local set_default proved + // nondeterministic under parallel `cargo test` (callsite interest + // caching); the global layer observes every event, and this test + // filters by its unique temp manifest_path. + use std::collections::BTreeMap; + use std::sync::{Arc, Mutex, OnceLock}; + use tracing::field::{Field, Visit}; + use tracing::{Event, Level, Subscriber}; + use tracing_subscriber::layer::{Context, Layer, SubscriberExt}; + + struct Captured { + message: String, + fields: BTreeMap, + } + #[derive(Default)] + struct V { + message: String, + fields: BTreeMap, + } + impl Visit for V { + fn record_debug(&mut self, f: &Field, v: &dyn std::fmt::Debug) { + let r = format!("{v:?}"); + if f.name() == "message" { + self.message = r; + } else { + self.fields.insert(f.name().into(), r); + } + } + fn record_str(&mut self, f: &Field, v: &str) { + if f.name() == "message" { + self.message = v.into(); + } else { + self.fields.insert(f.name().into(), v.into()); + } + } + } + struct CaptureLayer { + events: Arc>>, + } + impl Layer for CaptureLayer { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + if *event.metadata().level() != Level::WARN { + return; + } + let mut v = V::default(); + event.record(&mut v); + self.events.lock().expect("capture lock").push(Captured { + message: v.message, + fields: v.fields, + }); + } + } + + static GLOBAL_EVENTS: OnceLock>>> = OnceLock::new(); + let events = GLOBAL_EVENTS.get_or_init(|| { + let events = Arc::new(Mutex::new(Vec::new())); + let layer = CaptureLayer { + events: Arc::clone(&events), + }; + // Ignore the error case: some OTHER test installed a global + // subscriber first — then this assertion would fail noisily + // below, but no freshell-server test does that today. + let _ = + tracing::subscriber::set_global_default(tracing_subscriber::registry().with(layer)); + events + }); + + let root = tmp(); + let bad_json = root.join("bad-json"); + std::fs::create_dir_all(&bad_json).unwrap(); + std::fs::write(bad_json.join(MANIFEST_FILE), "{ nope").unwrap(); + write_manifest( + &root, + "bad-schema", + r#"{ "name": "x", "version": "1", "label": "l", "description": "d", "category": "cli" }"#, + ); + write_manifest(&root, "claude-code", CLAUDE_MANIFEST); + + let reg = ExtensionRegistry::scan(std::slice::from_ref(&root)); + assert_eq!(reg.discovered_cli_names(), vec!["claude"]); + + let root_marker = root.display().to_string(); + let mine: Vec = events + .lock() + .expect("capture lock") + .iter() + .filter(|e| { + e.fields + .get("manifest_path") + .is_some_and(|p| p.contains(&root_marker)) + }) + .map(|e| e.message.clone()) + .collect(); + assert_eq!( + mine.len(), + 2, + "one warn per rejected manifest, got {mine:?}" + ); + assert!( + mine.iter().any(|m| m.contains("invalid JSON in manifest")), + "JSON-parse warn line present: {mine:?}" + ); + assert!( + mine.iter() + .any(|m| m.contains("invalid manifest") && !m.contains("invalid JSON")), + "schema-invalid warn line present: {mine:?}" + ); + std::fs::remove_dir_all(&root).ok(); + } + #[test] fn scan_discovers_cli_manifests_and_dedups_first_wins() { let root = tmp(); diff --git a/crates/freshell-server/src/legacy_local_seed.rs b/crates/freshell-server/src/legacy_local_seed.rs new file mode 100644 index 000000000..9b98e3162 --- /dev/null +++ b/crates/freshell-server/src/legacy_local_seed.rs @@ -0,0 +1,697 @@ +//! CFG-04: the `legacyLocalSettingsSeed` extraction/merge contract, ported from +//! `shared/settings.ts` (`extractLegacyLocalSettingsSeed` + +//! `normalizeExtractedLocalSeed` + the seed half of `mergeLocalSettings`). +//! +//! A legacy (pre-settings-split) `config.json` carries browser-local preferences +//! INSIDE `settings` (theme, uiScale, terminal font, sidebar presentation, +//! notification sound, ...). The legacy Node server +//! (`server/config-store.ts#ConfigStore.loadInternal`) extracts them once into a +//! top-level `legacyLocalSettingsSeed`, strips them from the live server-settings +//! tree, and serves the seed via `/api/bootstrap` so a fresh browser/WebView +//! profile can seed its local preferences exactly once (the client owns the +//! one-time marker; `src/lib/browser-preferences.ts`). This module owns the +//! pure extraction/merge half of that contract for the Rust server; +//! `crate::settings_store` owns the boot-time wiring. +//! +//! Fidelity: every test below pins output against the REAL legacy functions +//! executed via `tsx` on the frozen base (the "oracle battery"), including +//! byte-exact `JSON.stringify`-vs-`serde_json::to_string` comparisons — key +//! order (the workspace enables serde_json's `preserve_order`) and JS number +//! serialization (integral floats print as `1`, never `1.0`) are observable in +//! side-by-side operation with the legacy server on the same home, so they are +//! part of the contract, not an implementation detail. + +use serde_json::{json, Map, Value}; + +/// `FRESH_AGENT_LOCAL_KEYS` (`shared/settings.ts`) — the only pick-list that +/// survives as a table; every other section's members are written out inline in +/// the extractor because each member carries its own normalization rule (enum / +/// clamp / typeof), and the inline sequence IS the pick list, in declaration +/// order. +const FRESH_AGENT_LOCAL_KEYS: [&str; 3] = ["showThinking", "showTools", "showTimecodes"]; + +const THEME_VALUES: [&str; 3] = ["system", "light", "dark"]; +const TERMINAL_THEME_VALUES: [&str; 8] = [ + "auto", + "dracula", + "one-dark", + "solarized-dark", + "github-dark", + "one-light", + "solarized-light", + "github-light", +]; +const OSC52_CLIPBOARD_VALUES: [&str; 3] = ["ask", "always", "never"]; +const TERMINAL_RENDERER_VALUES: [&str; 3] = ["auto", "webgl", "canvas"]; +const TAB_ATTENTION_STYLE_VALUES: [&str; 4] = ["highlight", "pulse", "darken", "none"]; +const ATTENTION_DISMISS_VALUES: [&str; 2] = ["click", "type"]; +const SESSION_OPEN_MODE_VALUES: [&str; 2] = ["tab", "split"]; +const SIDEBAR_SORT_MODE_VALUES: [&str; 4] = ["recency", "recency-pinned", "activity", "project"]; +const WORKTREE_GROUPING_VALUES: [&str; 2] = ["repo", "worktree"]; +const DECK_TILE_STYLE_VALUES: [&str; 2] = ["status-icons", "terminal-previews"]; +const DECK_KEY_LAYOUT_VALUES: [&str; 3] = ["auto", "newest-first", "status-sorted"]; + +// Clamp ranges (`shared/settings.ts` constants). +const UI_SCALE_MIN: f64 = 0.75; +const UI_SCALE_MAX: f64 = 4.0; +const TERMINAL_FONT_SIZE_MIN: f64 = 12.0; +const TERMINAL_FONT_SIZE_MAX: f64 = 64.0; +const TERMINAL_LINE_HEIGHT_MIN: f64 = 1.0; +const TERMINAL_LINE_HEIGHT_MAX: f64 = 1.8; +const PANE_SNAP_THRESHOLD_MIN: f64 = 0.0; +const PANE_SNAP_THRESHOLD_MAX: f64 = 8.0; +const TAB_BAR_ROWS_MIN: f64 = 1.0; +const TAB_BAR_ROWS_MAX: f64 = 10.0; +const SIDEBAR_WIDTH_MIN: f64 = 200.0; +const SIDEBAR_WIDTH_MAX: f64 = 500.0; + +pub fn extract_legacy_local_settings_seed(raw: &Value) -> Option { + let obj = raw.as_object()?; + + let mut out: Map = Map::new(); + + // theme / uiScale (top level, in the legacy normalize assignment order). + if let Some(theme) = obj.get("theme").and_then(|v| enum_string(v, &THEME_VALUES)) { + out.insert("theme".to_string(), theme); + } + if let Some(ui_scale) = normalize_clamped_number(obj.get("uiScale"), UI_SCALE_MIN, UI_SCALE_MAX) + { + out.insert("uiScale".to_string(), js_number(ui_scale)); + } + + if let Some(terminal) = obj.get("terminal").and_then(Value::as_object) { + let mut section: Map = Map::new(); + if let Some(v) = normalize_rounded_clamped_number( + terminal.get("fontSize"), + TERMINAL_FONT_SIZE_MIN, + TERMINAL_FONT_SIZE_MAX, + ) { + section.insert("fontSize".to_string(), js_number(v)); + } + // `typeof === 'string'` — even an empty string survives (legacy fidelity). + if let Some(v) = terminal.get("fontFamily").and_then(Value::as_str) { + section.insert("fontFamily".to_string(), json!(v)); + } + if let Some(v) = normalize_clamped_number( + terminal.get("lineHeight"), + TERMINAL_LINE_HEIGHT_MIN, + TERMINAL_LINE_HEIGHT_MAX, + ) { + section.insert("lineHeight".to_string(), js_number(v)); + } + if let Some(v) = terminal.get("cursorBlink").and_then(Value::as_bool) { + section.insert("cursorBlink".to_string(), json!(v)); + } + if let Some(v) = terminal + .get("theme") + .and_then(|v| enum_string(v, &TERMINAL_THEME_VALUES)) + { + section.insert("theme".to_string(), v); + } + if let Some(v) = terminal.get("warnExternalLinks").and_then(Value::as_bool) { + section.insert("warnExternalLinks".to_string(), json!(v)); + } + if let Some(v) = terminal + .get("osc52Clipboard") + .and_then(|v| enum_string(v, &OSC52_CLIPBOARD_VALUES)) + { + section.insert("osc52Clipboard".to_string(), v); + } + if let Some(v) = terminal + .get("renderer") + .and_then(|v| enum_string(v, &TERMINAL_RENDERER_VALUES)) + { + section.insert("renderer".to_string(), v); + } + assign_section(&mut out, "terminal", section); + } + + if let Some(panes) = obj.get("panes").and_then(Value::as_object) { + let mut section: Map = Map::new(); + if let Some(v) = normalize_rounded_clamped_number( + panes.get("snapThreshold"), + PANE_SNAP_THRESHOLD_MIN, + PANE_SNAP_THRESHOLD_MAX, + ) { + section.insert("snapThreshold".to_string(), js_number(v)); + } + if let Some(v) = panes.get("iconsOnTabs").and_then(Value::as_bool) { + section.insert("iconsOnTabs".to_string(), json!(v)); + } + if let Some(v) = panes + .get("tabAttentionStyle") + .and_then(|v| enum_string(v, &TAB_ATTENTION_STYLE_VALUES)) + { + section.insert("tabAttentionStyle".to_string(), v); + } + if let Some(v) = panes + .get("attentionDismiss") + .and_then(|v| enum_string(v, &ATTENTION_DISMISS_VALUES)) + { + section.insert("attentionDismiss".to_string(), v); + } + if let Some(v) = panes + .get("sessionOpenMode") + .and_then(|v| enum_string(v, &SESSION_OPEN_MODE_VALUES)) + { + section.insert("sessionOpenMode".to_string(), v); + } + if let Some(v) = panes.get("multirowTabs").and_then(Value::as_bool) { + section.insert("multirowTabs".to_string(), json!(v)); + } + if let Some(v) = panes.get("repoIconsOnTabs").and_then(Value::as_bool) { + section.insert("repoIconsOnTabs".to_string(), json!(v)); + } + if let Some(v) = normalize_rounded_clamped_number( + panes.get("tabBarRows"), + TAB_BAR_ROWS_MIN, + TAB_BAR_ROWS_MAX, + ) { + section.insert("tabBarRows".to_string(), js_number(v)); + } + assign_section(&mut out, "panes", section); + } + + if let Some(sidebar) = obj.get("sidebar").and_then(Value::as_object) { + // Present keys are picked raw (incl. null) and then normalized; the + // `ignoreCodexSubagentSessions` legacy alias fills the canonical key + // only when the canonical key is ABSENT (a present-but-invalid + // canonical key suppresses the alias and drops — oracle-pinned). + let mut section: Map = Map::new(); + if let Some(v) = sidebar.get("sortMode") { + section.insert("sortMode".to_string(), normalize_local_sort_mode(v)); + } + if let Some(v) = sidebar.get("worktreeGrouping") { + section.insert( + "worktreeGrouping".to_string(), + normalize_worktree_grouping(v), + ); + } + if let Some(v) = sidebar.get("showProjectBadges").and_then(Value::as_bool) { + section.insert("showProjectBadges".to_string(), json!(v)); + } + if let Some(v) = sidebar.get("showSubagents").and_then(Value::as_bool) { + section.insert("showSubagents".to_string(), json!(v)); + } + let ignore_codex_subagents = sidebar + .get("ignoreCodexSubagents") + .and_then(Value::as_bool) + .or_else(|| { + if sidebar.contains_key("ignoreCodexSubagents") { + None + } else { + sidebar + .get("ignoreCodexSubagentSessions") + .and_then(Value::as_bool) + } + }); + if let Some(v) = ignore_codex_subagents { + section.insert("ignoreCodexSubagents".to_string(), json!(v)); + } + if let Some(v) = sidebar + .get("showNoninteractiveSessions") + .and_then(Value::as_bool) + { + section.insert("showNoninteractiveSessions".to_string(), json!(v)); + } + if let Some(v) = sidebar.get("hideEmptySessions").and_then(Value::as_bool) { + section.insert("hideEmptySessions".to_string(), json!(v)); + } + if let Some(v) = normalize_rounded_clamped_number( + sidebar.get("width"), + SIDEBAR_WIDTH_MIN, + SIDEBAR_WIDTH_MAX, + ) { + section.insert("width".to_string(), js_number(v)); + } + if let Some(v) = sidebar.get("collapsed").and_then(Value::as_bool) { + section.insert("collapsed".to_string(), json!(v)); + } + assign_section(&mut out, "sidebar", section); + } + + // freshAgent local keys survive a legacy `agentChat` alias via a shallow + // per-key alias-merge with the canonical `freshAgent` winning + // (`migrateLegacyFreshAgentSettingsInput` restricted to the three local + // boolean keys this seed can carry). + let merged_fresh_agent = merge_alias_shallow( + obj.get("agentChat").and_then(Value::as_object), + obj.get("freshAgent").and_then(Value::as_object), + ); + if let Some(fresh_agent) = merged_fresh_agent { + let mut section: Map = Map::new(); + for key in FRESH_AGENT_LOCAL_KEYS { + if let Some(v) = fresh_agent.get(key).and_then(Value::as_bool) { + section.insert(key.to_string(), json!(v)); + } + } + assign_section(&mut out, "freshAgent", section); + } + + if let Some(notifications) = obj.get("notifications").and_then(Value::as_object) { + let mut section: Map = Map::new(); + if let Some(v) = notifications.get("soundEnabled").and_then(Value::as_bool) { + section.insert("soundEnabled".to_string(), json!(v)); + } + assign_section(&mut out, "notifications", section); + } + + if let Some(stream_deck) = obj.get("streamDeck").and_then(Value::as_object) { + let mut section: Map = Map::new(); + if let Some(v) = stream_deck.get("enabled").and_then(Value::as_bool) { + section.insert("enabled".to_string(), json!(v)); + } + // `brightness`/`idleBrightness`/`idleTimeoutSeconds` are typeof-checked + // but deliberately NOT clamped on the legacy side. + for key in ["brightness", "idleBrightness", "idleTimeoutSeconds"] { + if let Some(v) = stream_deck.get(key).and_then(Value::as_f64) { + if v.is_finite() { + section.insert(key.to_string(), js_number(v)); + } + } + } + if let Some(v) = stream_deck + .get("tileStyle") + .and_then(|v| enum_string(v, &DECK_TILE_STYLE_VALUES)) + { + section.insert("tileStyle".to_string(), v); + } + if let Some(v) = stream_deck + .get("keyLayout") + .and_then(|v| enum_string(v, &DECK_KEY_LAYOUT_VALUES)) + { + section.insert("keyLayout".to_string(), v); + } + assign_section(&mut out, "streamDeck", section); + } + + if out.is_empty() { + None + } else { + Some(Value::Object(out)) + } +} + +/// The seed merge of `config-store.ts#loadInternal`: +/// `stored ? mergeLocalSettings(extracted, stored) : extracted`. Both inputs are +/// already-normalized patches (or absent), so the full `mergeLocalSettings` +/// reduces to: start from extracted (key order kept), override `theme`/`uiScale` +/// when the stored patch owns them, and member-merge each section with the +/// stored patch's members winning (`mergeDefined`); empty sections vanish. +/// `mergeLocalSettings`'s sortMode/worktreeGrouping/freshAgent re-normalizations +/// are no-ops on already-normalized input and are intentionally not repeated. +pub fn merge_legacy_seeds(extracted: Option<&Value>, stored: Option<&Value>) -> Option { + let Some(stored) = stored else { + return extracted.cloned(); + }; + let stored_obj = stored.as_object(); + let mut out: Map = extracted + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + if let Some(patch) = stored_obj { + if let Some(v) = patch.get("theme") { + out.insert("theme".to_string(), v.clone()); + } + if let Some(v) = patch.get("uiScale") { + out.insert("uiScale".to_string(), v.clone()); + } + for section in [ + "terminal", + "panes", + "sidebar", + "freshAgent", + "notifications", + "streamDeck", + ] { + let merged_section = merge_defined( + out.get(section).and_then(Value::as_object), + patch.get(section).and_then(Value::as_object), + ); + if !merged_section.is_empty() { + out.insert(section.to_string(), Value::Object(merged_section)); + } + } + } + if out.is_empty() { + None + } else { + Some(Value::Object(out)) + } +} + +// ── helpers ──────────────────────────────────────────────────────────────── + +/// Legacy `clampNumber` (`Math.min(max, Math.max(min, value))`) behind the +/// `normalizeClampedNumber` typeof/finite gate; absent/wrong-typed → None. +fn normalize_clamped_number(value: Option<&Value>, min: f64, max: f64) -> Option { + value + .and_then(Value::as_f64) + .filter(|n| n.is_finite()) + .map(|n| n.clamp(min, max)) +} + +/// `normalizeRoundedClampedNumber`: clamp, then `Math.round`. +fn normalize_rounded_clamped_number(value: Option<&Value>, min: f64, max: f64) -> Option { + normalize_clamped_number(value, min, max).map(|n| n.round()) +} + +/// `z.enum(VALUES).safeParse(v).success ? v : dropped`. Only JSON strings +/// qualify (numbers/objects fail the parse identically on the Node side). +fn enum_string(value: &Value, allowed: &[&str]) -> Option { + value + .as_str() + .filter(|s| allowed.contains(s)) + .map(|s| json!(s)) +} + +/// `normalizeLocalSortMode`: 'hybrid' → 'activity'; invalid (incl. null) → +/// 'activity'. The legacy assignment fires whenever the key is PRESENT — +/// including null — so this is a total function, not an Option. +fn normalize_local_sort_mode(value: &Value) -> Value { + match value.as_str() { + Some("hybrid") => json!("activity"), + Some(s) if SIDEBAR_SORT_MODE_VALUES.contains(&s) => json!(s), + _ => json!("activity"), + } +} + +/// `normalizeWorktreeGrouping`: invalid (incl. null) → 'repo'. Total function. +fn normalize_worktree_grouping(value: &Value) -> Value { + match value.as_str() { + Some(s) if WORKTREE_GROUPING_VALUES.contains(&s) => json!(s), + _ => json!("repo"), + } +} + +/// JS `JSON.stringify` number parity: integral values persist as integers +/// (`1`, never `1.0`), non-integral as the shortest f64 form. serde_json's +/// `Value` distinguishes integer/float representations, so this conversion is +/// required for byte-stable side-by-side operation with the legacy server. +fn js_number(n: f64) -> Value { + if n.fract() == 0.0 && n.abs() <= 9_007_199_254_740_992.0 { + Value::from(n as i64) + } else { + Value::from(n) + } +} + +/// `maybeAssignNested`: an empty section is dropped, not persisted. +fn assign_section(out: &mut Map, key: &str, section: Map) { + if !section.is_empty() { + out.insert(key.to_string(), Value::Object(section)); + } +} + +/// Shallow per-key alias merge `{...legacy, ...canonical}` (canonical wins), +/// restricted to object inputs (`readLegacyFreshAgentSettingsInput` + +/// `mergeFreshAgentAliasObjects` reduced to the semantics observable through +/// the three local boolean keys). +fn merge_alias_shallow( + legacy: Option<&Map>, + canonical: Option<&Map>, +) -> Option> { + if legacy.is_none() && canonical.is_none() { + return None; + } + let mut merged = legacy.cloned().unwrap_or_default(); + if let Some(canonical) = canonical { + for (k, v) in canonical { + merged.insert(k.clone(), v.clone()); + } + } + Some(merged) +} + +/// `mergeDefined(base, patch)` — `{...base}` overlaid with every patch entry +/// (JS `undefined` cannot occur in JSON, so every entry copies). +fn merge_defined( + base: Option<&Map>, + patch: Option<&Map>, +) -> Map { + let mut merged = base.cloned().unwrap_or_default(); + if let Some(patch) = patch { + for (k, v) in patch { + merged.insert(k.clone(), v.clone()); + } + } + merged +} + +#[cfg(test)] +mod tests { + //! Every expectation below was produced by executing the REAL legacy + //! `extractLegacyLocalSettingsSeed`/`mergeLocalSettings` (`shared/settings.ts`) + //! under tsx on the frozen base and pasting its `JSON.stringify` output. Byte + //! comparisons are `serde_json::to_string(result) == `. + + use super::*; + + fn extract(raw: Value) -> Option { + extract_legacy_local_settings_seed(&raw) + } + + fn as_json_string(value: &Value) -> String { + serde_json::to_string(value).expect("serializable") + } + + /// The crown jewel: a full legacy mixed config's seed, byte-identical to the + /// legacy server's extraction (`JSON.stringify` on the Node side). + #[test] + fn full_mixed_seed_byte_matches_legacy() { + let raw = json!({ + "theme": "light", "uiScale": 1.25, + "terminal": { "scrollback": 4000, "fontSize": 18, "fontFamily": "Fira Code", "lineHeight": 1.4, "cursorBlink": false, "theme": "dracula", "warnExternalLinks": true, "osc52Clipboard": "always", "renderer": "canvas" }, + "panes": { "defaultNewPane": "shell", "snapThreshold": 3.6, "iconsOnTabs": true, "tabAttentionStyle": "pulse", "attentionDismiss": "type", "sessionOpenMode": "split", "multirowTabs": true, "repoIconsOnTabs": false, "tabBarRows": 5 }, + "sidebar": { "excludeFirstChatSubstrings": ["welcome"], "excludeFirstChatMustStart": false, "autoGenerateTitles": true, "sortMode": "project", "worktreeGrouping": "worktree", "showProjectBadges": false, "showSubagents": true, "ignoreCodexSubagents": true, "showNoninteractiveSessions": true, "hideEmptySessions": true, "width": 280, "collapsed": true }, + "freshAgent": { "showThinking": false, "showTools": true, "showTimecodes": true, "enabled": true }, + "notifications": { "soundEnabled": false }, + "streamDeck": { "enabled": true, "brightness": 2.5, "idleBrightness": 1, "idleTimeoutSeconds": 300, "tileStyle": "terminal-previews", "keyLayout": "newest-first" } + }); + let seed = extract(raw).expect("seed extracted"); + assert_eq!( + as_json_string(&seed), + r#"{"theme":"light","uiScale":1.25,"terminal":{"fontSize":18,"fontFamily":"Fira Code","lineHeight":1.4,"cursorBlink":false,"theme":"dracula","warnExternalLinks":true,"osc52Clipboard":"always","renderer":"canvas"},"panes":{"snapThreshold":4,"iconsOnTabs":true,"tabAttentionStyle":"pulse","attentionDismiss":"type","sessionOpenMode":"split","multirowTabs":true,"repoIconsOnTabs":false,"tabBarRows":5},"sidebar":{"sortMode":"project","worktreeGrouping":"worktree","showProjectBadges":false,"showSubagents":true,"ignoreCodexSubagents":true,"showNoninteractiveSessions":true,"hideEmptySessions":true,"width":280,"collapsed":true},"freshAgent":{"showThinking":false,"showTools":true,"showTimecodes":true},"notifications":{"soundEnabled":false},"streamDeck":{"enabled":true,"brightness":2.5,"idleBrightness":1,"idleTimeoutSeconds":300,"tileStyle":"terminal-previews","keyLayout":"newest-first"}}"# + ); + } + + /// Out-of-range numerics are CLAMPED, never dropped (legacy `clampNumber`); + /// rounded members round (`snapThreshold` 3.6 -> 4 above; tabBarRows 0 -> min). + #[test] + fn clamps_min_side_byte_match() { + let raw = json!({ + "uiScale": -5, + "terminal": { "fontSize": 1_000_000, "lineHeight": 0.2 }, + "panes": { "snapThreshold": 99, "tabBarRows": 0 }, + "sidebar": { "width": 99999 } + }); + let seed = extract(raw).expect("seed extracted"); + assert_eq!( + as_json_string(&seed), + r#"{"uiScale":0.75,"terminal":{"fontSize":64,"lineHeight":1},"panes":{"snapThreshold":8,"tabBarRows":1},"sidebar":{"width":500}}"# + ); + } + + #[test] + fn clamps_max_side_byte_match() { + let raw = json!({ + "uiScale": 99, + "terminal": { "fontSize": 1, "lineHeight": 9 }, + "panes": { "snapThreshold": -4, "tabBarRows": 99 }, + "sidebar": { "width": 1 } + }); + let seed = extract(raw).expect("seed extracted"); + assert_eq!( + as_json_string(&seed), + r#"{"uiScale":4,"terminal":{"fontSize":12,"lineHeight":1.8},"panes":{"snapThreshold":0,"tabBarRows":10},"sidebar":{"width":200}}"# + ); + } + + /// Invalid enum members are DROPPED; when nothing valid survives anywhere in + /// the patch, the whole extraction is None (legacy `undefined`). + #[test] + fn invalid_enums_drop_leaving_none() { + let raw = json!({ + "theme": "neon", + "terminal": { "theme": "matrix", "renderer": "opengl", "osc52Clipboard": "sometimes" }, + "panes": { "tabAttentionStyle": "blink", "attentionDismiss": "hover", "sessionOpenMode": "drawer" }, + "streamDeck": { "tileStyle": "big", "keyLayout": "grid" } + }); + assert_eq!(extract(raw), None); + } + + /// `sortMode`/`worktreeGrouping` are DEFAULT-FILLED, not dropped, whenever the + /// key is present: hybrid -> activity, unknown -> activity/repo, null -> + /// activity/repo (legacy `hasOwn` + `normalizeLocalSortMode`). + #[test] + fn sort_mode_and_grouping_default_fill() { + let hybrid = + extract(json!({ "sidebar": { "sortMode": "hybrid", "worktreeGrouping": "banana" } })) + .expect("seed extracted"); + assert_eq!( + as_json_string(&hybrid), + r#"{"sidebar":{"sortMode":"activity","worktreeGrouping":"repo"}}"# + ); + let nulls = extract(json!({ + "theme": null, "terminal": null, "uiScale": null, + "sidebar": { "sortMode": null, "width": null } + })) + .expect("seed extracted"); + assert_eq!( + as_json_string(&nulls), + r#"{"sidebar":{"sortMode":"activity"}}"# + ); + let null_grouping = + extract(json!({ "sidebar": { "worktreeGrouping": null } })).expect("seed extracted"); + assert_eq!( + as_json_string(&null_grouping), + r#"{"sidebar":{"worktreeGrouping":"repo"}}"# + ); + } + + /// The `ignoreCodexSubagentSessions` legacy alias fills `ignoreCodexSubagents` + /// ONLY when the canonical key is absent; a present-but-invalid canonical key + /// suppresses the alias (and itself drops, yielding nothing). + #[test] + fn subagent_alias_semantics() { + let alias = extract(json!({ "sidebar": { "ignoreCodexSubagentSessions": true } })) + .expect("seed extracted"); + assert_eq!( + as_json_string(&alias), + r#"{"sidebar":{"ignoreCodexSubagents":true}}"# + ); + let canonical_wins = extract(json!({ + "sidebar": { "ignoreCodexSubagentSessions": true, "ignoreCodexSubagents": false } + })) + .expect("seed extracted"); + assert_eq!( + as_json_string(&canonical_wins), + r#"{"sidebar":{"ignoreCodexSubagents":false}}"# + ); + let canonical_invalid = extract(json!({ + "sidebar": { "ignoreCodexSubagentSessions": true, "ignoreCodexSubagents": "yes" } + })); + assert_eq!(canonical_invalid, None); + } + + /// The `agentChat` -> `freshAgent` alias merges shallowly with canonical wins + /// per key (`migrateLegacyFreshAgentSettingsInput`): `showThinking` comes from + /// canonical (true), `showTools` survives from legacy (false). + #[test] + fn agent_chat_alias_canonical_wins_per_key() { + let raw = json!({ + "agentChat": { "showThinking": false, "showTools": false, "enabled": true }, + "freshAgent": { "showThinking": true, "showTimecodes": true } + }); + let seed = extract(raw).expect("seed extracted"); + assert_eq!( + as_json_string(&seed), + r#"{"freshAgent":{"showThinking":true,"showTools":false,"showTimecodes":true}}"# + ); + } + + #[test] + fn empty_and_non_object_inputs_yield_none() { + assert_eq!(extract(json!({})), None); + assert_eq!(extract(json!("not-an-object")), None); + assert_eq!(extract(json!(null)), None); + assert_eq!(extract(json!([])), None); + assert_eq!(extract(json!({ "settings": {} })), None); // no local keys at top level + } + + /// Wrong-typed members drop; if nothing remains, extraction is None. + #[test] + fn invalid_member_types_drop_leaving_none() { + let raw = json!({ + "theme": 5, "uiScale": "big", + "terminal": { "fontSize": "18", "fontFamily": null, "cursorBlink": "yes" }, + "notifications": { "soundEnabled": "no" } + }); + assert_eq!(extract(raw), None); + } + + /// JS number serialization: integral floats persist as integers (`1`, never + /// `1.0`) — required for byte-stable side-by-side config operation with the + /// legacy server (`JSON.stringify` number semantics). + #[test] + fn integral_floats_serialize_as_integers() { + let raw = json!({ + "uiScale": 1.0, + "terminal": { "fontSize": 18.0, "lineHeight": 1.0 }, + "panes": { "snapThreshold": 3.0 }, + "sidebar": { "width": 280.0 } + }); + let seed = extract(raw).expect("seed extracted"); + assert_eq!( + as_json_string(&seed), + r#"{"uiScale":1,"terminal":{"fontSize":18,"lineHeight":1},"panes":{"snapThreshold":3},"sidebar":{"width":280}}"# + ); + } + + /// `streamDeck` numerics are typeof-checked but NOT clamped. + #[test] + fn streamdeck_numbers_unclamped() { + let seed = + extract(json!({ "streamDeck": { "brightness": 2.5, "idleTimeoutSeconds": 12.75 } })) + .expect("seed extracted"); + assert_eq!( + as_json_string(&seed), + r#"{"streamDeck":{"brightness":2.5,"idleTimeoutSeconds":12.75}}"# + ); + } + + /// Canonically-ordered output regardless of input key order (the extract + /// emits theme, uiScale, terminal, panes, sidebar, freshAgent, notifications, + /// streamDeck — the legacy normalize function's assignment order). + #[test] + fn scrambled_input_emits_canonical_order() { + let seed = extract(json!({ + "notifications": { "soundEnabled": false }, + "theme": "dark", + "sidebar": { "sortMode": "project" } + })) + .expect("seed extracted"); + assert_eq!( + as_json_string(&seed), + r#"{"theme":"dark","sidebar":{"sortMode":"project"},"notifications":{"soundEnabled":false}}"# + ); + } + + /// Node: `stored ? mergeLocalSettings(extracted, stored) : extracted` — the + /// stored seed wins per key on conflict. + #[test] + fn merge_stored_wins_on_conflict() { + let extracted = extract(json!({ "theme": "light", "uiScale": 1.5 })); + let stored = extract(json!({ "theme": "dark" })); + let merged = merge_legacy_seeds(extracted.as_ref(), stored.as_ref()).expect("merged"); + assert_eq!(as_json_string(&merged), r#"{"theme":"dark","uiScale":1.5}"#); + } + + /// Sections merge member-wise; a base-only key keeps its position, patch-new + /// top-level keys append in the legacy fixed order (theme before + /// notifications), matching `mergeLocalSettings`'s assignment order. + #[test] + fn merge_sections_from_both_sides() { + let extracted = extract(json!({ "terminal": { "fontSize": 20 } })); + let stored = + extract(json!({ "notifications": { "soundEnabled": false }, "theme": "dark" })); + let merged = merge_legacy_seeds(extracted.as_ref(), stored.as_ref()).expect("merged"); + assert_eq!( + as_json_string(&merged), + r#"{"terminal":{"fontSize":20},"theme":"dark","notifications":{"soundEnabled":false}}"# + ); + } + + /// With no stored seed, the extracted seed passes through + /// (`config-store.ts:337-339`'s `stored ? merge : extracted`). With neither, + /// the seed is None. + #[test] + fn merge_passthrough_and_empty() { + let extracted = extract(json!({ "theme": "light" })); + assert_eq!( + merge_legacy_seeds(extracted.as_ref(), None), + extracted.clone() + ); + assert_eq!(merge_legacy_seeds(None, None), None); + } +} diff --git a/crates/freshell-server/src/logging.rs b/crates/freshell-server/src/logging.rs index 75ceee36a..cffc3ec83 100644 --- a/crates/freshell-server/src/logging.rs +++ b/crates/freshell-server/src/logging.rs @@ -1,13 +1,69 @@ -//! Structured JSONL logging (DIAG-01 slice) with size-based rotation and -//! from-the-first-byte secret redaction (DIAG-03 slice). +//! Structured JSONL logging (DIAG-01) with size-based rotation and +//! from-the-first-byte secret redaction (DIAG-03). //! -//! ## Scope (deliberately shrunk, per the validated plan) +//! ## Canonical line schema (the Tauri-ready contract) //! -//! `tracing`-based JSONL logs written to `/.freshell/logs/rust-server.jsonl`: +//! Every line written to `/.freshell/logs/rust-server.jsonl` is one +//! self-describing JSON object. Fields every line ALWAYS carries: +//! +//! - `ts` RFC3339-millis `Z` UTC timestamp (tracing event time) +//! - `level` severity: `TRACE`/`DEBUG`/`INFO`/`WARN`/`ERROR` +//! - `target` component: the emitting crate's module path (e.g. +//! `freshell_ws::terminal`, `freshell_terminal::registry`) +//! - `msg` human summary or dotted event name (`terminal.created`, +//! `ws.connection.closed`, `server.started`); events that prefer prose in +//! `msg` additionally carry an `event` field with the dotted +//! machine-readable name +//! - `app_version` the release that wrote the line (DIAG-01 "app version"; +//! resolved once at boot from `FRESHELL_APP_VERSION`/the build constant, +//! so any arbitrary log tail is attributable) +//! - `server_pid` the server process that wrote the line (DIAG-01 process +//! ownership of the WRITER; child processes an event spawns report their +//! own `pid` field alongside) +//! +//! Context fields, flattened into the same object when applicable (span +//! fields merge root->leaf, then the event's own fields win collisions): +//! +//! - HTTP requests: `request_id`, `route`, `method`, `status`, +//! `duration_ms` (one `http_request` event per response; see +//! [`request_logging_middleware`]) +//! - WS connections: `connection_id`, `origin_kind` -- carried on the +//! `ws.connection.established`/`closed` lifecycle events, via the +//! per-connection `ws_conn` span on every event emitted while serving +//! that connection, AND as explicit event fields on the create-reply +//! settle companions (`ws.terminal.create.settled` with +//! `connection_id`/`request_id`/`terminal_id`/`path`). Ownership- +//! envelope NOTE: span enrichment is active under bare level filters +//! (any `RUST_LOG=error..trace`) and globally-anchored mixes +//! (`info,freshell_terminal=debug`); tracing-subscriber disables span +//! callsites under TARGET-DIRECTIVE-ONLY filters (even a matched +//! `freshell_ws=info`), which is exactly why the settle companions +//! carry the join as event fields -- event fields ride through any +//! filter admitting the event. Under `freshell_ws=off` nothing in the +//! ws crate logs at all (operator's express choice); a +//! `terminal.created` line then joins only via `terminal_id`. +//! - Terminals: `terminal_id` plus spawn-mode/cwd/pid on +//! `terminal.created`, `exit_code` on `terminal.exited`, the kill actor +//! (`by`: api/idle/shutdown) on `terminal.killed` +//! - Fresh agents: `provider` + `session_id` on the session lifecycle +//! events (`freshagent.session.created`, `...session.crash_detected`, +//! `...sidecar.reaped`, crash-recovery events); `freshagent.sidecar.spawned` +//! carries `provider` + the spawned process `pid` (no `session_id` -- +//! the spawn can precede session minting on the create path; the +//! pid<->session join is the same session's adjacent created/reaped +//! events). Prompt/turn CONTENT is never logged anywhere. +//! - Server lifecycle: `server.started` (`bind`, `port`, `boot_id`, +//! `instance_id`, `commit`, `dirty`) -> `server.stopping` (`signal`) +//! -> `server.stopped` (plus the `shutdown_forensics` diagnostic record) +//! +//! A Tauri host-side producer (or any other Rust component of the desktop +//! app) emitting THIS EXACT SHAPE -- with its own `app_version` and its +//! process's pid -- produces a coherent, combinable diagnostic stream; the +//! schema is deliberately free of server-only assumptions so that producer +//! can be layered on without a contract change. +//! +//! ## Rotation + redaction (DIAG-03) //! -//! - **Structured fields**: `ts`, `level`, `target`, `msg`, plus a per-HTTP- -//! request correlation id (`request_id`) and route/method/status/duration -//! for every request ([`request_logging_middleware`]). //! - **Size-based rotation**, bounded total: [`DEFAULT_MAX_BYTES`] per file //! (10 MiB) x [`DEFAULT_MAX_BACKUPS`] backups (2) = 3 files total, //! overridable via `FRESHELL_LOG_MAX_BYTES`/`FRESHELL_LOG_MAX_BACKUPS`. @@ -31,22 +87,15 @@ //! - The pre-existing single stdout ("`freshell-server listening on //! ...`") line is left untouched for compat -- this module is additive. //! -//! ## NOT in scope (see the DIAG-01/DIAG-03 checklist text for the full -//! acceptance criteria this slice does not attempt) +//! ## NOT in scope //! //! - OTLP/telemetry export, remote log shipping. -//! - Full WS connect/disconnect+reason and terminal spawn/exit event -//! wiring: those lifecycles live inside `freshell-ws`/`freshell-terminal` -//! (crates this slice's ownership boundary does not touch to avoid -//! colliding with concurrent work on those crates). The global request -//! middleware below DOES log the initial `/ws` upgrade request (route, -//! status, duration), which is partial coverage. -//! - `settings_store.rs` persistence events (that file is explicitly -//! frozen for this slice). //! - Client-log ingestion (`DIAG-02`), live debug/perf toggles (`DIAG-04`). //! -//! See `crates/freshell-server/tests/diag01_diag03_logging.rs` for the -//! outer, black-box, operator-experience proof of this slice. +//! See `crates/freshell-server/tests/diag01_diag03_logging.rs` (rotation/ +//! redaction) and `crates/freshell-server/tests/diag01_lifecycle_logging.rs` +//! (full-flow schema + correlation + restart) for the outer, black-box, +//! operator-experience proof of this contract. use std::fs::{self, File, OpenOptions}; use std::io::Write; @@ -92,6 +141,13 @@ pub struct LoggingConfig { /// every log line. Never logged itself, including here (this struct is /// never `Debug`-derived/printed). pub secret: String, + /// The app version stamped onto EVERY line as `app_version` (DIAG-01's + /// "app version" field): any arbitrary log tail is then attributable to + /// the release that produced it without cross-referencing boot lines. + /// Resolved by the caller (`main.rs`: `FRESHELL_APP_VERSION` env -> + /// `APP_VERSION` const) so this module stays env-free about versioning + /// and tests can inject a known value. + pub app_version: String, } /// Resolve [`LoggingConfig`] from the environment, mirroring the legacy @@ -99,7 +155,7 @@ pub struct LoggingConfig { /// `resolveDebugLogPath`) and adding two new, narrowly-scoped overrides /// (`FRESHELL_LOG_MAX_BYTES`/`FRESHELL_LOG_MAX_BACKUPS`) so the rotation /// bound is testable without waiting to actually accumulate 10 MiB. -pub fn resolve_config(home: Option<&Path>, secret: String) -> LoggingConfig { +pub fn resolve_config(home: Option<&Path>, secret: String, app_version: String) -> LoggingConfig { let log_dir = std::env::var("FRESHELL_LOG_DIR") .ok() .filter(|v| !v.is_empty()) @@ -121,6 +177,7 @@ pub fn resolve_config(home: Option<&Path>, secret: String) -> LoggingConfig { max_bytes, max_backups, secret, + app_version, } } @@ -139,7 +196,11 @@ pub fn init(config: LoggingConfig) -> std::io::Result<()> { let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); - let json_layer = JsonLayer { writer }; + let json_layer = JsonLayer { + writer, + app_version: config.app_version, + server_pid: std::process::id() as u64, + }; let subscriber = tracing_subscriber::registry() .with(env_filter) .with(json_layer); @@ -361,12 +422,24 @@ impl Visit for JsonVisitor { /// `route`, `status`, `duration_ms`), and writes it through a /// [`RotatingWriter`] (which redacts before any byte reaches disk). /// +/// Every line is additionally stamped with `app_version` (the release that +/// produced it) and `server_pid` (the process that wrote it) -- DIAG-01's +/// app-version and process-ownership requirements at per-line granularity, +/// so ANY arbitrary log tail is self-attributing (which build, which +/// process) without cross-referencing a boot line that may have rotated +/// away. Both are inserted into the base field map BEFORE span/event fields +/// merge, so an event that legitimately carries its own `pid` (e.g. a +/// spawned child's pid on `terminal.created`) keeps its own meaning -- +/// `server_pid` never collides with it. +/// /// Hand-rolled rather than `tracing_subscriber::fmt`'s JSON formatter /// because `fmt` hardcodes different field names (`timestamp`/`message`, /// nested `fields`/`span` objects) with no rename hook -- reimplementing the /// ~80 lines below is simpler than fighting that shape. struct JsonLayer { writer: RotatingWriter, + app_version: String, + server_pid: u64, } /// Span-local storage for this layer: the JSON fields recorded when the @@ -413,6 +486,15 @@ where "target".to_string(), Value::String(event.metadata().target().to_string()), ); + // DIAG-01 per-line identity: the emitting build + process, before + // any span/event fields merge (event-level fields would win a + // collision; no call site uses either name -- verified by rg across + // all server-side crates when this was added). + map.insert( + "app_version".to_string(), + Value::String(self.app_version.clone()), + ); + map.insert("server_pid".to_string(), Value::from(self.server_pid)); // Merge span-chain fields root -> leaf, so the innermost span's // fields win on any (unexpected) key collision. @@ -527,6 +609,38 @@ pub async fn request_logging_middleware(req: Request, next: Next) -> Response { mod tests { use super::*; + #[test] + fn every_line_stamps_app_version_and_server_pid() { + let dir = std::env::temp_dir().join(format!( + "freshell-logging-stamp-test-{}-{:?}", + std::process::id(), + std::time::SystemTime::now() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("rust-server.jsonl"); + let writer = RotatingWriter::create(path.clone(), 1 << 20, 1, String::new()).unwrap(); + let layer = JsonLayer { + writer, + app_version: "9.9.9-test".to_string(), + server_pid: 424242, + }; + let subscriber = tracing_subscriber::registry().with(layer); + tracing::subscriber::with_default(subscriber, || { + tracing::info!(route = "/api/health", "http_request"); + }); + let content = std::fs::read_to_string(&path).unwrap(); + let line: serde_json::Value = + serde_json::from_str(content.lines().next().unwrap()).unwrap(); + assert_eq!(line["app_version"], serde_json::json!("9.9.9-test")); + assert_eq!(line["server_pid"], serde_json::json!(424242u64)); + // The pre-existing envelope fields must still be there. + assert!(line["ts"].as_str().unwrap().ends_with('Z')); + assert_eq!(line["level"], serde_json::json!("INFO")); + assert_eq!(line["msg"], serde_json::json!("http_request")); + assert_eq!(line["route"], serde_json::json!("/api/health")); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn scrub_redacts_the_exact_secret_value_wherever_it_appears() { let secret = "s3cr3t-abc123"; diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index 07fee3885..69a20e78b 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -29,11 +29,13 @@ mod extensions; mod files; mod identity_sink; mod instance_id; +mod legacy_local_seed; mod logging; mod managed_ports; mod migrations; mod net_bind; mod network; +mod project_colors; mod proxy; mod rate_limit; mod recovery_inventory; @@ -51,6 +53,9 @@ mod settings_store; mod shutdown_forensics; mod tabs_snapshots; mod terminals; +#[cfg(test)] +pub(crate) mod test_clock_gate; +mod test_clock_router; mod updater; use std::net::IpAddr; @@ -168,13 +173,28 @@ async fn main() -> ExitCode { let port = resolve_port(); let home = resolve_home(); + // The app version string, resolved ONCE here (before logging init, which + // stamps it onto every line) and shared (Arc::clone) into BOTH + // `GET /api/version` (`currentVersion`) and `GET /api/health` (`version`), + // so the two endpoints can never disagree. Overridable via + // `FRESHELL_APP_VERSION`. Pure env read + constant -- no dependency on + // anything built later, so it is safe to resolve this early. + let app_version = + Arc::new(std::env::var("FRESHELL_APP_VERSION").unwrap_or_else(|_| APP_VERSION.to_string())); + // DIAG-01/DIAG-03: structured JSONL logging to // `/.freshell/logs/rust-server.jsonl`, redacted from the first // byte (the live AUTH_TOKEN is the ONE secret this process itself - // knows verbatim). A failure here (e.g. an unwritable log dir) must - // never prevent boot -- the pre-existing stderr "listening on" line - // below still gets the operator to a running server either way. - let logging_config = logging::resolve_config(home.as_deref(), auth_token.as_str().to_string()); + // knows verbatim) and stamped per-line with the app version + server + // pid (DIAG-01's app-version / process-ownership fields). A failure + // here (e.g. an unwritable log dir) must never prevent boot -- the + // pre-existing stderr "listening on" line below still gets the + // operator to a running server either way. + let logging_config = logging::resolve_config( + home.as_deref(), + auth_token.as_str().to_string(), + app_version.as_str().to_string(), + ); if let Err(err) = logging::init(logging_config) { eprintln!("freshell-server: structured logging disabled: {err}"); } @@ -225,12 +245,6 @@ async fn main() -> ExitCode { // `server_instance_id`. let boot_id = Arc::new(format!("boot-{}", Uuid::new_v4())); - // The app version string, resolved ONCE and shared (Arc::clone) into BOTH - // `GET /api/version` (`currentVersion`) and `GET /api/health` (`version`), so - // the two endpoints can never disagree. Overridable via `FRESHELL_APP_VERSION`. - let app_version = - Arc::new(std::env::var("FRESHELL_APP_VERSION").unwrap_or_else(|_| APP_VERSION.to_string())); - // The server-start timestamp, captured once here as an ISO-8601 string // (millisecond precision + `Z`, matching JS `Date.toISOString()` in // `server/health-router.ts`). Surfaced as health `startedAt`. @@ -439,7 +453,16 @@ async fn main() -> ExitCode { // or lowered it from the default had no effect). See // `freshell_ws::spawn_idle_monitor` for the periodic sweep this feeds. registry.set_auto_kill_idle_minutes(settings.safety.auto_kill_idle_minutes); - freshell_ws::spawn_idle_monitor(registry.clone(), std::time::Duration::from_secs(30)); + // HARNESS-14: under the env-gated test clock the sweep cadence shrinks + // to 250ms so tests observe an advanced clock promptly (the sweep still + // ticks on real time; only the threshold math follows the virtual one). + // Production (gate off) keeps the legacy 30s cadence exactly. + let idle_sweep_interval = if freshell_platform::clock::enabled() { + std::time::Duration::from_millis(250) + } else { + std::time::Duration::from_secs(30) + }; + freshell_ws::spawn_idle_monitor(registry.clone(), idle_sweep_interval); // e2e knob (kata znhn item 2): sub-second flap cycles would trip the // registry generation cap (3 per 30s liveness window) before the hub's // circuit breaker can ever fire. Production default unchanged. @@ -932,8 +955,16 @@ async fn main() -> ExitCode { auth_token: Arc::clone(&auth_token), // Shared (not moved) so `GET /api/health` reports the SAME `instanceId`. server_instance_id: Arc::clone(&server_instance_id), - boot_id, + // Shared (not moved) so the DIAG-01 `server.started` lifecycle event + // (emitted after the listener binds, below) can log the SAME boot id. + boot_id: Arc::clone(&boot_id), settings: Arc::clone(&settings), + // CFG-12: the /ws handshake's `settings.updated` resolves the LIVE + // store per connection (legacy parity: per-connection + // `handshakeSnapshotProvider` -> `configStore.getSettings()`), so a + // PATCH committed after boot reaches the next (re)connecting client. + // `settings` above stays the boot-frozen create-time view (CFG-06). + handshake_settings: settings_store.shared_settings_lock(), config_fallback: config_fallback.clone(), broadcast_tx: Arc::clone(&broadcast_tx), fresh_codex: fresh_codex_state.clone(), @@ -1394,7 +1425,7 @@ async fn main() -> ExitCode { // derivation of these defaults and the deliberate global-vs-per-IP scope // decision). let rate_limiter = - rate_limit::RateLimiter::new_system(rate_limit::RateLimitConfig::default_api()); + rate_limit::RateLimiter::new_gate_aware(rate_limit::RateLimitConfig::default_api()); // DIAG-05: `/api/server-info`, `/api/debug`, `/api/perf` -- shares the // live settings store, terminal registry, tabs registry, and session @@ -1494,6 +1525,16 @@ async fn main() -> ExitCode { gemini: gemini.clone(), index: sessions_state_index, })) + .merge(project_colors::router(project_colors::ProjectColorsState { + auth_token: Arc::clone(&auth_token), + settings: settings_store.clone(), + broadcast_tx: Arc::clone(&broadcast_tx), + // SESSION-05: a project-color write broadcasts `sessions.changed` + // on the SAME unified revision sequence as the override-write/ + // sweep producers (the sweep is structurally blind to this + // config-only change; see `sessions::SessionsState::sessions_revision`). + sessions_revision: Arc::clone(&sessions_revision), + })) .merge(resolve::router(resolve::ResolveState { auth_token: Arc::clone(&auth_token), // SYNC-06 deleted-override filter: the SAME settings store the @@ -1592,6 +1633,15 @@ async fn main() -> ExitCode { .merge(terminals::router(terminals_state)) .merge(proxy::router(proxy_state)) .merge(screenshots::router(screenshots_state)) + // HARNESS-14: the test-clock control surface exists ONLY when + // `FRESHELL_TEST_CLOCK` enabled the clock at boot (and its handlers + // re-check the gate, so even a misplaced merge could never expose + // it). A normal build answers 404 like any unmatched `/api/*`. + .merge(test_clock_router::router( + test_clock_router::TestClockState { + auth_token: Arc::clone(&auth_token), + }, + )) .fallback({ let client_dir = Arc::clone(&client_dir); move |uri: axum::http::Uri, headers: axum::http::HeaderMap| { @@ -1659,6 +1709,22 @@ async fn main() -> ExitCode { &diag::iso8601_utc(now_secs), ) ); + // DIAG-01 lifecycle context: the ONE authoritative STRUCTURED boot + // record (the stderr line above is for terminal tails; this event is + // what log parses key on). `app_version`/`server_pid` are stamped on + // every line by the logging layer, so the boot record carries the + // per-installation identity (`instance_id`, CFG-07), the per-boot + // restart signal (`boot_id`), and build provenance (`commit`/`dirty`, + // the same values `GET /api/server-info` reports). + tracing::info!( + bind = %boot_ip, + port, + boot_id = %boot_id.as_str(), + instance_id = %server_instance_id.as_str(), + commit = diag::build_commit(), + dirty = diag::build_dirty_str(), + "server.started" + ); // Block until SIGTERM/SIGINT (the same graceful-shutdown trigger the old // `axum::serve(...).with_graceful_shutdown(...)` used), then drain the @@ -1718,6 +1784,13 @@ async fn main() -> ExitCode { freshell_codex::launch_lifecycle::CodexTerminalLaunchManager::global() .shutdown() .await; + // DIAG-01 lifecycle context: the terminal "we are done" marker. Every + // owner above has run (WS drain, registry kill_all, all three fresh-agent + // sidecar reapers, the codex launch manager); the logging writer flushes + // synchronously per line, so this line is guaranteed on disk before the + // process exits -- the DIAG-03 "final shutdown event is flushed" clause + // holds by construction here. + tracing::info!("server.stopped"); ExitCode::SUCCESS } @@ -1775,6 +1848,11 @@ async fn shutdown_signal( _ = hangup => "SIGHUP", }; + // DIAG-01 lifecycle context: the clean "we are going down" marker, FIRST + // (before the drain below), so a log tail always answers "did this + // server stop intentionally, and on which signal". + tracing::info!(signal = signal_name, "server.stopping"); + // Latch FIRST (Task 7 wired this — keep it before any teardown): gated // creates consult this flag around registry.create. shutdown_started.store(true, std::sync::atomic::Ordering::SeqCst); diff --git a/crates/freshell-server/src/project_colors.rs b/crates/freshell-server/src/project_colors.rs new file mode 100644 index 000000000..bf88eb85f --- /dev/null +++ b/crates/freshell-server/src/project_colors.rs @@ -0,0 +1,551 @@ +//! `PUT /api/project-colors` — the project-color write half of SESSION-05. +//! Faithful port of `server/project-colors-router.ts` +//! (`ProjectColorSchema`: `projectPath: string.min(1).max(1024)`, +//! `color: string.min(1).max(64)`) backed by +//! [`crate::settings_store::SettingsStore::set_project_color`]. +//! +//! Broadcast parity: the legacy route ends with +//! `await codingCliIndexer.refresh()` +//! (`project-colors-router.ts:25`), and a refresh whose project-group +//! snapshot differs republishes `sessions.changed` +//! (`sessions-sync/service.ts`). The Rust session sweep +//! (`spawn_sessions_sweep`, `main.rs`) is structurally blind to +//! config-only changes (its `(count, max lastActivityAt)` signature never +//! moves on a color write — the same documented gap class the GAP-1 fix +//! closed for override writes), so — exactly like +//! `sessions::patch_session` — this route broadcasts `sessions.changed` +//! DIRECTLY on a successful write, bumping the SAME shared +//! `sessions_revision` counter so the revision stays on one unified +//! sequence with the sweep/override/fresh-agent producers. +//! +//! Error surfacing: the legacy route AWAITS `configStore.setProjectColor` +//! before responding, so a failed save is a failed request — but its +//! express-4 async handler has no error wrapper, making the exact legacy +//! failure behavior process-undefined. This port surfaces a failed persist +//! as a plain 500 `{error}` envelope (same shape as +//! `SettingsStore::patch`'s GAP2 surfacing) — a documented deliberate +//! hardening, recorded in `docs/plans/df1-evidence/SESSION-05.md`. + +use std::sync::Arc; + +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::put, + Json, Router, +}; +use serde_json::{json, Value}; + +use crate::boot::{is_authed, unauthorized}; +use crate::settings_store::SettingsStore; + +/// The `ProjectColorSchema` string limits (`project-colors-router.ts:5-6`). +const PROJECT_PATH_MAX: usize = 1024; +const COLOR_MAX: usize = 64; + +/// Shared state for the project-colors write surface. +#[derive(Clone)] +pub struct ProjectColorsState { + pub auth_token: Arc, + pub settings: SettingsStore, + /// The shared WS broadcast bus + revision counter (the SAME + /// `Arc` as `WsState::sessions_revision`, + /// `sessions::SessionsState::sessions_revision`, and the sweep), so a + /// color write broadcasts `sessions.changed` on the unified sequence. + pub broadcast_tx: Arc>, + pub sessions_revision: Arc, +} + +/// The project-colors sub-router (`PUT /api/project-colors`). +pub fn router(state: ProjectColorsState) -> Router { + Router::new() + .route("/api/project-colors", put(put_project_color)) + .with_state(state) +} + +/// zod's "received" word for an `invalid_type` issue, derived from the +/// actual JSON value (`received undefined` for a missing key, matching +/// `safeParse(req.body || {})` — see `validate_project_color_body`). +fn received_word(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +/// `ProjectColorSchema.safeParse(req.body || {})` +/// (`project-colors-router.ts:4-7, 19`): BOTH fields required; per-field +/// checks in schema order (`projectPath`, then `color`), issues collected +/// across fields like zod. Issue shapes are byte-matched to a live zod +/// v4.3.6 probe of the ORIGINAL schema (see +/// `docs/plans/df1/SESSION-05.md` A1): `invalid_type` for +/// missing/null/wrong-type, `too_small`/`too_big` for the string bounds. +/// `None` = valid. +fn validate_project_color_body(body: &Value) -> Option { + // `req.body || {}` (`project-colors-router.ts:19`): a falsy JSON body + // (null / false / 0 / "") means the original validates `{}` and + // reports BOTH fields missing. + static EMPTY: std::sync::OnceLock = std::sync::OnceLock::new(); + let empty = || EMPTY.get_or_init(|| json!({})); + let body = match body { + Value::Null | Value::Bool(false) => empty(), + Value::String(s) if s.is_empty() => empty(), + Value::Number(n) if n.as_i64() == Some(0) || n.as_f64() == Some(0.0) => empty(), + other => other, + }; + let Value::Object(map) = body else { + return Some(json!([{ + "code": "invalid_type", + "expected": "object", + "path": [], + "message": format!( + "Invalid input: expected object, received {}", + received_word(body) + ), + }])); + }; + let mut issues: Vec = Vec::new(); + for (key, max) in [("projectPath", PROJECT_PATH_MAX), ("color", COLOR_MAX)] { + match map.get(key) { + Some(Value::String(s)) => { + // zod's `.min(1)`/`.max(N)` operate on JS `string.length` + // (UTF-16 code units), NOT bytes/codepoints — count UTF-16 + // units so near-limit non-ASCII paths validate identically. + let js_len = s.encode_utf16().count(); + if js_len < 1 { + issues.push(json!({ + "code": "too_small", + "minimum": 1, + "origin": "string", + "inclusive": true, + "path": [key], + "message": "Too small: expected string to have >=1 characters", + })); + } else if js_len > max { + issues.push(json!({ + "code": "too_big", + "maximum": max, + "origin": "string", + "inclusive": true, + "path": [key], + "message": format!( + "Too big: expected string to have <={max} characters" + ), + })); + } + } + Some(v) => issues.push(json!({ + "code": "invalid_type", + "expected": "string", + "path": [key], + "message": format!( + "Invalid input: expected string, received {}", + received_word(v) + ), + })), + None => issues.push(json!({ + "code": "invalid_type", + "expected": "string", + "path": [key], + "message": "Invalid input: expected string, received undefined", + })), + } + } + if issues.is_empty() { + None + } else { + Some(Value::Array(issues)) + } +} + +/// `PUT /api/project-colors` (`project-colors-router.ts:18-27`): validate +/// the body, persist the color, broadcast `sessions.changed`, respond +/// `{ok:true}`. The refresh the original performs re-reads +/// `configStore.getProjectColors()` into the project groups — here the +/// client re-reads the colors through the refetch that follows the +/// broadcast (the session-directory page embeds `projectColors`, see +/// `session_directory.rs`), which is the SAME observable client behavior. +async fn put_project_color( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Response { + if !is_authed(&headers, &state.auth_token) { + return unauthorized(); + } + if let Some(details) = validate_project_color_body(&body) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Invalid request", "details": details })), + ) + .into_response(); + } + let map = body.as_object().expect("validated as object above"); + let project_path = map["projectPath"].as_str().expect("validated string"); + let color = map["color"].as_str().expect("validated string"); + + if let Err(err) = state.settings.set_project_color(project_path, color).await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": err.to_string() })), + ) + .into_response(); + } + + // Broadcast AFTER a successful persist (legacy equivalent: the + // refresh AFTER `await setProjectColor` — + // `project-colors-router.ts:24-25`). On the ONE unified + // `sessions_revision` sequence (see `SessionsState::sessions_revision`). + let revision = state + .sessions_revision + .fetch_add(1, std::sync::atomic::Ordering::SeqCst) + + 1; + let frame = json!({ "type": "sessions.changed", "revision": revision }).to_string(); + let _ = state.broadcast_tx.send(frame); + + Json(json!({ "ok": true })).into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + + fn state_at( + dir: &std::path::Path, + ) -> (ProjectColorsState, tokio::sync::broadcast::Receiver) { + let (tx, rx) = tokio::sync::broadcast::channel::(16); + ( + ProjectColorsState { + auth_token: Arc::new("tok".to_string()), + settings: SettingsStore::load(Some(dir), vec!["claude".into(), "codex".into()]), + broadcast_tx: Arc::new(tx), + sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + }, + rx, + ) + } + + async fn put_json(app: &Router, token: Option<&str>, body: &Value) -> (StatusCode, Value) { + let mut req = Request::builder() + .method("PUT") + .uri("/api/project-colors") + .header("content-type", "application/json"); + if let Some(token) = token { + req = req.header("x-auth-token", token); + } + let resp = app + .clone() + .oneshot(req.body(Body::from(body.to_string())).unwrap()) + .await + .unwrap(); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).unwrap() + }; + (status, json) + } + + fn uuid_like() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("{:x}-{:x}", nanos, std::process::id()) + } + + /// UNAUTH: no token → the same 401 as every other authed route + /// (`httpAuthMiddleware` / `is_authed`). + #[tokio::test] + async fn put_requires_auth() { + let dir = std::env::temp_dir().join(format!("frs-project-colors-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + let (state, _rx) = state_at(&dir); + let app = router(state); + + let (status, _body) = put_json( + &app, + None, + &json!({ "projectPath": "/proj/a", "color": "#ff0000" }), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// VALIDATION, missing fields: `{}` and `{}`-equivalent falsy bodies + /// report BOTH fields (`safeParse(req.body || {})`, + /// `project-colors-router.ts:19`) with the legacy 400 envelope; the + /// integration suite pins this (`api-edge-cases.test.ts` "rejects + /// empty body" / "rejects missing projectPath" / "rejects missing + /// color"). + #[tokio::test] + async fn put_rejects_missing_fields_with_both_zod_issues() { + let dir = std::env::temp_dir().join(format!("frs-project-colors-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + let (state, _rx) = state_at(&dir); + let app = router(state); + + for (label, body) in [ + ("empty object", json!({})), + ("json null", Value::Null), + // `req.body || {}` — falsy scalars validate as `{}` in the + // original. + ("json false", json!(false)), + ("json zero", json!(0)), + ("json empty string", json!("")), + ] { + let (status, resp) = put_json(&app, Some("tok"), &body).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{label}"); + assert_eq!(resp["error"], json!("Invalid request"), "{label}"); + let details = resp["details"].as_array().expect("details array"); + assert_eq!(details.len(), 2, "{label}: both fields reported"); + assert_eq!(details[0]["code"], json!("invalid_type")); + assert_eq!(details[0]["path"], json!(["projectPath"])); + assert_eq!(details[1]["path"], json!(["color"])); + } + + std::fs::remove_dir_all(&dir).ok(); + } + + /// VALIDATION, wrong types/nulls/empty/over-limit — one 400 per class, + /// matching the zod issue codes of the original schema (live-probed, + /// see module doc / plan A1). + #[tokio::test] + async fn put_rejects_null_and_wrong_type_and_empty_and_over_limit() { + let dir = std::env::temp_dir().join(format!("frs-project-colors-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + let (state, _rx) = state_at(&dir); + let app = router(state); + + // nulls + let (status, resp) = put_json( + &app, + Some("tok"), + &json!({ "projectPath": null, "color": null }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "nulls"); + assert_eq!(resp["details"][0]["code"], json!("invalid_type")); + + // wrong type + let (status, resp) = put_json( + &app, + Some("tok"), + &json!({ "projectPath": 42, "color": "#fff" }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "wrong type"); + assert_eq!(resp["details"][0]["path"], json!(["projectPath"])); + + // empty strings → too_small + let (status, resp) = put_json( + &app, + Some("tok"), + &json!({ "projectPath": "", "color": "" }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "empty strings"); + let details = resp["details"].as_array().unwrap(); + assert_eq!(details.len(), 2); + assert_eq!(details[0]["code"], json!("too_small")); + assert_eq!(details[1]["code"], json!("too_small")); + + // over the limits → too_big + let (status, resp) = put_json( + &app, + Some("tok"), + &json!({ + "projectPath": "x".repeat(PROJECT_PATH_MAX + 1), + "color": "y".repeat(COLOR_MAX + 1), + }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "over limit"); + let details = resp["details"].as_array().unwrap(); + assert_eq!(details.len(), 2); + assert_eq!(details[0]["code"], json!("too_big")); + assert_eq!(details[0]["maximum"], json!(PROJECT_PATH_MAX as u64)); + assert_eq!(details[1]["maximum"], json!(COLOR_MAX as u64)); + + // non-object body (array) → object-level invalid_type + let (status, resp) = put_json(&app, Some("tok"), &json!([])).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "array body"); + assert_eq!(resp["details"][0]["expected"], json!("object")); + + // UTF-16 LENGTH PARITY: zod measures string lengths in JS + // `string.length` (UTF-16 units). A 64-unit / 192-byte non-ASCII + // color must ACCEPT (byte-counting would wrongly 400)... + let (status, _) = put_json( + &app, + Some("tok"), + &json!({ "projectPath": "/proj/a", "color": "€".repeat(64) }), + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "64 UTF-16 units (192 bytes) is at the limit" + ); + // ...and 65 units must still reject. + let (status, resp) = put_json( + &app, + Some("tok"), + &json!({ "projectPath": "/proj/a", "color": "€".repeat(65) }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "65 UTF-16 units exceeds"); + assert_eq!(resp["details"][0]["code"], json!("too_big")); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// HAPPY PATH: 200 `{ok:true}`; the color is in `config.json`; an + /// unrelated pre-existing color key survives; an extra body key is + /// ignored (zod strips unknown keys)... and the SAME write broadcasts + /// `sessions.changed` on the shared revision sequence. + #[tokio::test] + async fn put_persists_color_and_broadcasts_sessions_changed() { + let dir = std::env::temp_dir().join(format!("frs-project-colors-{}", uuid_like())); + let freshell = dir.join(".freshell"); + std::fs::create_dir_all(&freshell).unwrap(); + std::fs::write( + freshell.join("config.json"), + serde_json::to_string(&json!({ + "version": 1, + "settings": {}, + "sessionOverrides": { "claude:s1": { "titleOverride": "KeepMe" } }, + "projectColors": { "/proj/keep": "#123456" } + })) + .unwrap(), + ) + .unwrap(); + let (state, mut rx) = state_at(&dir); + let app = router(state); + + let (status, resp) = put_json( + &app, + Some("tok"), + &json!({ "projectPath": "/proj/new", "color": "#ff8800", "junk": 1 }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(resp, json!({ "ok": true })); + + // On disk: the new color, the pre-existing one, and the unrelated + // session override all survive. + let cfg: Value = + serde_json::from_str(&std::fs::read_to_string(freshell.join("config.json")).unwrap()) + .unwrap(); + assert_eq!(cfg["projectColors"]["/proj/new"], json!("#ff8800")); + assert_eq!(cfg["projectColors"]["/proj/keep"], json!("#123456")); + assert_eq!( + cfg["sessionOverrides"]["claude:s1"]["titleOverride"], + json!("KeepMe") + ); + + // Broadcast fired AFTER the persist, revision bumped from 0 to 1. + let frame = rx.try_recv().expect("a sessions.changed frame"); + let parsed: Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(parsed["type"], json!("sessions.changed")); + assert_eq!(parsed["revision"], json!(1)); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// REVISION MONOTONICITY: two writes produce strictly increasing + /// revisions (the client treats a stalled revision as no-change — + /// `App.tsx:1143`). Also proves the second write keeps the first path. + #[tokio::test] + async fn put_broadcasts_monotonically_increasing_revisions() { + let dir = std::env::temp_dir().join(format!("frs-project-colors-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + let (state, mut rx) = state_at(&dir); + let app = router(state); + + for (path, color, expected_rev) in + [("/proj/a", "#111111", 1u64), ("/proj/b", "#222222", 2u64)] + { + let (status, _) = put_json( + &app, + Some("tok"), + &json!({ "projectPath": path, "color": color }), + ) + .await; + assert_eq!(status, StatusCode::OK); + let frame = rx.try_recv().expect("a sessions.changed frame"); + let parsed: Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(parsed["revision"], json!(expected_rev)); + } + + let cfg: Value = serde_json::from_str( + &std::fs::read_to_string(dir.join(".freshell").join("config.json")).unwrap(), + ) + .unwrap(); + assert_eq!(cfg["projectColors"]["/proj/a"], json!("#111111")); + assert_eq!(cfg["projectColors"]["/proj/b"], json!("#222222")); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// 500 SURFACING: a config directory Rust cannot write to fails the + /// request (the legacy route AWAITS the save → a failed save is a + /// failed request; this port can actually encode it). The color change + /// must NOT be reported as persisted. + #[cfg(unix)] + #[tokio::test] + async fn put_surfaces_a_persist_failure_as_500() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("frs-project-colors-{}", uuid_like())); + let freshell = dir.join(".freshell"); + std::fs::create_dir_all(&freshell).unwrap(); + // Boot with a writable dir (load seeds config.json), then make it + // read+execute only so the tmp-file create inside persist fails. + let (state, mut rx) = state_at(&dir); + let original_perms = std::fs::metadata(&freshell).unwrap().permissions(); + std::fs::set_permissions(&freshell, std::fs::Permissions::from_mode(0o500)).unwrap(); + // Clone the shared store handle BEFORE `router` consumes `state` + // so the post-failure in-memory assertion below can read it. + let store = state.settings.clone(); + let app = router(state); + + let (status, resp) = put_json( + &app, + Some("tok"), + &json!({ "projectPath": "/proj/a", "color": "#111111" }), + ) + .await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert!( + resp["error"].as_str().is_some(), + "the error envelope must be human-readable: {resp:?}" + ); + // No broadcast for a failed write. + assert!(rx.try_recv().is_err(), "no sessions.changed on failure"); + // The failed color must not leak into the live in-memory map + // (legacy parity: `saveInternal` updates the cache only AFTER the + // atomic write succeeds — `config-store.ts:424-435`). + let colors = store.project_colors(); + assert!( + !colors.contains_key("/proj/a"), + "a failed write must not become visible via project_colors(): {colors:?}" + ); + + std::fs::set_permissions(&freshell, original_perms).unwrap(); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/freshell-server/src/proxy.rs b/crates/freshell-server/src/proxy.rs index e38f691bc..419266ee7 100644 --- a/crates/freshell-server/src/proxy.rs +++ b/crates/freshell-server/src/proxy.rs @@ -14,9 +14,13 @@ //! ## Faithful behaviour (matches `proxy-router.ts`) //! * Target is always `127.0.0.1:` (never a remote host). //! * `` must be `1..=65535`, else `400 { error: "Invalid port number" }`. +//! * The upstream PATH+QUERY is the client's raw bytes, never percent-decoded +//! or re-encoded (legacy forwards `req.url` verbatim — `proxy-router.ts:99`; +//! `Path` extraction would decode `%2F`→`/` and corrupt routes). //! * The upstream request carries the incoming method + body and the incoming //! headers minus hop-by-hop framing (`host` is set to the target; //! `connection` / `transfer-encoding` are dropped — `proxy-router.ts:90-93`). +//! Repeated headers keep all values in wire order, both directions. //! * The response echoes the upstream status + headers **minus** the three //! iframe-blocking headers (and minus the framing headers `hyper` recomputes), //! streaming the body through unchanged. @@ -38,7 +42,7 @@ use std::sync::Arc; use axum::{ body::Body, - extract::{Path, State}, + extract::State, http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri}, response::{IntoResponse, Response}, routing::any, @@ -54,18 +58,55 @@ const IFRAME_BLOCKED_HEADERS: [&str; 3] = [ "content-security-policy-report-only", ]; -/// Hop-by-hop / framing response headers `hyper` recomputes for the outgoing -/// response; forwarding them verbatim alongside a streamed body would double-frame. -const HOP_BY_HOP_RESPONSE_HEADERS: [&str; 4] = [ - "connection", - "transfer-encoding", - "content-length", - "keep-alive", -]; +/// Hop-by-hop response headers `hyper` recomputes for the outgoing response; +/// forwarding `transfer-encoding`/`connection` verbatim alongside a re-framed +/// body would double-frame (or lie about) the wire. `content-length` is NOT in +/// this set: reqwest runs with zero compression features, so response bytes +/// are bit-exact (probe L4) and the upstream's declared length stays truthful +/// — and legacy forwards it (`proxy-router.ts:35` strips ONLY the three +/// iframe-blockers). +const HOP_BY_HOP_RESPONSE_HEADERS: [&str; 3] = ["connection", "transfer-encoding", "keep-alive"]; /// Request headers dropped before forwarding upstream (`proxy-router.ts:91-93`: /// `host` is rewritten to the target; `connection`/`transfer-encoding` dropped). -const STRIPPED_REQUEST_HEADERS: [&str; 3] = ["host", "connection", "transfer-encoding"]; +/// +/// SECURITY (wrap-review r3, deliberate hardening BEYOND the original legacy +/// design; the same strip was applied to `server/proxy-router.ts` so both +/// servers behave identically): `x-auth-token` is Freshell's own gate +/// credential — the original forwarded it (and the `freshell-auth` cookie, +/// filtered pair-wise in [`forward`]) to ANY proxied loopback app, handing a +/// hostile local dev server a bearer token that drives every authenticated +/// API/WS surface. A proxied app's OWN cookies/authorization still pass +/// through (its session/login flows keep working); only our credentials are +/// withheld. +const STRIPPED_REQUEST_HEADERS: [&str; 4] = + ["host", "connection", "transfer-encoding", "x-auth-token"]; + +/// The cookie NAME of Freshell's own gate credential ([`crate::boot::is_authed`] +/// reads exactly this pair). Filtered out of forwarded `Cookie` values — +/// upstream apps never learn the token, while their own cookies survive. +const AUTH_COOKIE_NAME: &str = "freshell-auth"; + +/// Rebuild a `Cookie` header value with every `freshell-auth` pair removed +/// (name-compared exactly like [`crate::boot::cookie_value`]). `None` when +/// nothing remains — the header is then dropped entirely. +fn filter_auth_cookie(raw: &str) -> Option { + let kept: Vec<&str> = raw + .split(';') + .map(str::trim) + .filter(|pair| !pair.is_empty()) + .filter(|pair| { + pair.split_once('=') + .map(|(name, _)| name.trim() != AUTH_COOKIE_NAME) + .unwrap_or(true) + }) + .collect(); + if kept.is_empty() { + None + } else { + Some(kept.join("; ")) + } +} /// Shared, cheaply-cloneable state for the proxy surface. #[derive(Clone)] @@ -82,6 +123,13 @@ impl ProxyState { pub fn new(auth_token: Arc) -> Self { let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) + // The upstream target is ALWAYS 127.0.0.1, and legacy's raw + // `http.request` never consults proxy env vars — but reqwest's + // builder defaults to auto-detecting `HTTP_PROXY`/`HTTPS_PROXY` + // ("system proxy"), which on some hosts would shunt the loopback + // request through a corporate/MITM proxy. Pin no-proxy so the + // loopback guarantee holds under any environment. + .no_proxy() .build() .unwrap_or_default(); Self { auth_token, client } @@ -105,17 +153,23 @@ pub fn router(state: ProxyState) -> Router { /// `/api/proxy/http/{*tail}` where `tail` is `` or `/`. async fn proxy( State(state): State, - Path(tail): Path, method: Method, headers: HeaderMap, uri: Uri, - body: axum::body::Bytes, + body: axum::body::Body, ) -> Response { - // Split `` off the front; the remainder (possibly empty) is the upstream - // path. `5173` → ("5173",""); `5173/` → ("5173",""); `5173/a/b` → ("5173","a/b"). + // Split `` off the front of the RAW, never-percent-decoded path + // (`uri.path()` — proven raw by `lb_probes::l1_...`). Extracting the + // catch-all as `Path` would percent-DECODE it (`%2F`→`/`, + // `%25`→`%`), corrupting routes that dev servers actually serve + // (Vite `/@fs/%2F…`, file names with `%20`). Legacy forwards Node's raw + // `req.url` untouched (`proxy-router.ts:99`); so do we. + // `5173` → ("5173",""); `5173/` → ("5173",""); `5173/a/b` → ("5173","a/b"). + let raw_path = uri.path(); + let tail = raw_path.strip_prefix("/api/proxy/http/").unwrap_or(""); let (port_raw, rest) = match tail.split_once('/') { Some((port, rest)) => (port, rest), - None => (tail.as_str(), ""), + None => (tail, ""), }; forward( state, @@ -137,7 +191,7 @@ async fn forward( method: Method, headers: HeaderMap, uri: Uri, - body: axum::body::Bytes, + body: axum::body::Body, ) -> Response { if !crate::boot::is_authed(&headers, &state.auth_token) { return unauthorized(); @@ -154,18 +208,53 @@ async fn forward( let target_url = format!("http://127.0.0.1:{target_port}/{rest}{query}"); // Convert the incoming method + headers to the upstream request. `host` is set - // by reqwest to the target; hop-by-hop framing headers are dropped. + // by reqwest to the target; hop-by-hop framing headers are dropped. Repeated + // headers APPEND (a repeated client header must reach upstream N times, in + // wire order — `proxy-router.ts` passes Node's header arrays through + // untouched); `insert` would collapse them to last-wins. let mut req = state.client.request(method, &target_url); let mut fwd_headers = HeaderMap::new(); for (name, value) in headers.iter() { if STRIPPED_REQUEST_HEADERS.contains(&name.as_str()) { continue; } - fwd_headers.insert(name.clone(), value.clone()); + if name == axum::http::header::COOKIE { + // Cookie values need PAIR-level filtering, not a whole-header + // drop: upstream apps keep their own cookies, only our + // `freshell-auth` credential pair is withheld. Unparseable + // values are dropped (fail closed for this header class). + let filtered = value.to_str().ok().and_then(filter_auth_cookie); + match filtered { + Some(joined) => match HeaderValue::from_str(&joined) { + Ok(v) => { + fwd_headers.append(name.clone(), v); + } + Err(_) => continue, + }, + None => continue, + } + continue; + } + fwd_headers.append(name.clone(), value.clone()); } req = req.headers(fwd_headers); - if !body.is_empty() { - req = req.body(body); + // Stream the request body (never buffer it): the incoming hyper body + // becomes the outgoing reqwest body chunk-for-chunk, so multi-MiB uploads + // pass through (no `Bytes` extraction → no 2 MiB `DefaultBodyLimit` 413) + // and chunked uploads are observable upstream incrementally — legacy's + // `req.pipe(proxyReq)` (`proxy-router.ts:119-120`). A body is attached + // only when the client DECLARED one (the only two HTTP/1.1 framings); + // attaching an empty streamed body to a plain GET would spontaneously + // re-frame it as `transfer-encoding: chunked`. + // The client's original `content-length` is among the forwarded headers + // (it is NOT in `STRIPPED_REQUEST_HEADERS`), and reqwest honors an + // explicit content-length alongside a streamed body (probe L5), so + // length-declared uploads keep their exact framing — legacy's header + // passthrough + piped bytes. + let declared_body = headers.contains_key(axum::http::header::CONTENT_LENGTH) + || headers.contains_key(axum::http::header::TRANSFER_ENCODING); + if declared_body { + req = req.body(reqwest::Body::wrap_stream(body.into_data_stream())); } let upstream = match req.send().await { @@ -181,6 +270,9 @@ async fn forward( // Rebuild the response: same status, headers minus the iframe-blockers and the // framing headers hyper recomputes, streaming the body through unchanged. + // Repeated headers APPEND so multi-`Set-Cookie` survives in wire order + // (`proxy-router.ts:35` hands `res.writeHead` the header array verbatim — + // collapsing it would silently kill proxied apps' session/login flows). let status = upstream.status(); let mut out_headers = HeaderMap::new(); for (name, value) in upstream.headers().iter() { @@ -194,7 +286,7 @@ async fn forward( HeaderName::from_bytes(name.as_ref()), HeaderValue::from_bytes(value.as_ref()), ) { - out_headers.insert(hn, hv); + out_headers.append(hn, hv); } } @@ -283,3 +375,1354 @@ mod tests { ); } } + +// ── Load-bearing probes (BROWSER-01, `docs/plans/df1/BROWSER-01.md`) ───────── +// +// One-shot EMPIRICAL validations of the assumptions the BROWSER-01 design +// rests on (plan §L1..L5). They deliberately probe framework behavior +// (axum routing/extraction, `http::HeaderMap`, reqwest body handling) rather +// than proxy correctness — the durable contract tests live in +// `mod socket_contract`. Raw sockets everywhere: no framework client/server +// on either side of the wire, so nothing normalizes the bytes being proven. +// ── Raw-wire test support (BROWSER-01) ───────────────────────────────── +// +// Shared raw-TCP fixtures used by the load-bearing probes and the +// socket-level contract tests: a verbatim upstream capture fixture, a raw +// client with wire-order duplicate-preserving header parsing, and +// ephemeral-loopback spawns for both the REAL proxy router and arbitrary +// standalone axum routers. No framework client/server on either side of +// the wire, so nothing normalizes the bytes being proven. +#[cfg(test)] +pub(crate) mod wire_support { + use super::*; + + pub(crate) const TEST_TOKEN: &str = "lb-probe-token-0123456789abcdef"; + + // ── Raw-wire helpers ──────────────────────────────────────────────────── + + /// One side of a raw TCP conversation plus a read-ahead buffer, so a + /// single `read()` that straddles the head/body boundary loses nothing. + pub(crate) struct Wire { + stream: tokio::net::TcpStream, + buf: Vec, + } + + pub(crate) fn find_subslice(hay: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() || hay.len() < needle.len() { + return None; + } + hay.windows(needle.len()).position(|w| w == needle) + } + + /// Parse `\r\n`-terminated head bytes into (first line, ordered headers). + /// Duplicate headers are preserved in wire order. + pub(crate) fn parse_head(raw: &[u8]) -> (String, Vec<(String, String)>) { + let text = String::from_utf8_lossy(raw); + let mut lines = text.split("\r\n"); + let first = lines.next().unwrap_or("").to_string(); + let headers = lines + .take_while(|l| !l.is_empty()) + .filter_map(|l| { + l.split_once(':') + .map(|(k, v)| (k.trim().to_string(), v.trim().to_string())) + }) + .collect(); + (first, headers) + } + + pub(crate) fn header_values<'a>( + headers: &'a [(String, String)], + name: &'a str, + ) -> impl Iterator { + headers + .iter() + .filter(move |(n, _)| n.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) + } + + impl Wire { + pub(crate) fn new(stream: tokio::net::TcpStream) -> Self { + Self { + stream, + buf: Vec::new(), + } + } + + pub(crate) async fn write_all(&mut self, bytes: &[u8]) { + use tokio::io::AsyncWriteExt; + self.stream.write_all(bytes).await.unwrap(); + } + + /// Read until `needle` (inclusive) and return it, retaining any + /// over-read bytes in the buffer. + pub(crate) async fn read_until(&mut self, needle: &[u8]) -> Vec { + use tokio::io::AsyncReadExt; + loop { + if let Some(pos) = find_subslice(&self.buf, needle) { + let end = pos + needle.len(); + return self.buf.drain(..end).collect(); + } + let mut tmp = [0u8; 8192]; + let n = self.stream.read(&mut tmp).await.unwrap(); + if n == 0 { + return std::mem::take(&mut self.buf); + } + self.buf.extend_from_slice(&tmp[..n]); + } + } + + pub(crate) async fn read_n(&mut self, n: usize) -> Vec { + use tokio::io::AsyncReadExt; + while self.buf.len() < n { + let mut tmp = [0u8; 8192]; + let r = self.stream.read(&mut tmp).await.unwrap(); + if r == 0 { + break; + } + self.buf.extend_from_slice(&tmp[..r]); + } + let take = self.buf.len().min(n); + self.buf.drain(..take).collect() + } + + pub(crate) async fn read_to_eof(&mut self) -> Vec { + use tokio::io::AsyncReadExt; + let mut tmp = [0u8; 8192]; + loop { + match self.stream.read(&mut tmp).await { + Ok(0) | Err(_) => break, + Ok(n) => self.buf.extend_from_slice(&tmp[..n]), + } + } + std::mem::take(&mut self.buf) + } + + /// Read one RFC 7230 chunked body (with optional trailer lines) and + /// return the reassembled payload. + pub(crate) async fn read_chunked(&mut self) -> Vec { + let mut body = Vec::new(); + loop { + let size_line = self.read_until(b"\r\n").await; + let size_text = String::from_utf8_lossy(&size_line); + let size_text = size_text.trim(); + let size = + usize::from_str_radix(size_text.split(';').next().unwrap_or("").trim(), 16) + .unwrap_or_else(|_| panic!("bad chunk size line {size_text:?}")); + if size == 0 { + // Trailer lines until a bare CRLF (common case: none). + loop { + let line = self.read_until(b"\r\n").await; + if line == b"\r\n" { + break; + } + } + break; + } + body.extend_from_slice(&self.read_n(size).await); + let crlf = self.read_n(2).await; + assert_eq!(crlf, b"\r\n", "chunk terminator"); + } + body + } + } + + /// A request as captured verbatim by the raw upstream fixture. + #[derive(Debug, Default)] + pub(crate) struct CapturedRequest { + pub(crate) request_line: String, + pub(crate) headers: Vec<(String, String)>, + pub(crate) body: Vec, + } + + impl CapturedRequest { + pub(crate) fn raw_target(&self) -> &str { + self.request_line.split(' ').nth(1).unwrap_or("") + } + pub(crate) fn header_values<'a>(&'a self, name: &'a str) -> impl Iterator { + header_values(&self.headers, name) + } + } + + /// Read one full request (head + framed body) from a wire. + pub(crate) async fn read_request(wire: &mut Wire) -> CapturedRequest { + let head = wire.read_until(b"\r\n\r\n").await; + let (request_line, headers) = parse_head(&head); + let body = if header_values(&headers, "transfer-encoding").any(|v| v.contains("chunked")) { + wire.read_chunked().await + } else if let Some(cl) = header_values(&headers, "content-length").next() { + wire.read_n(cl.parse().expect("content-length integer")) + .await + } else { + Vec::new() + }; + CapturedRequest { + request_line, + headers, + body, + } + } + + /// A full response as captured on the client side. + #[derive(Debug)] + pub(crate) struct RawResponse { + pub(crate) status_line: String, + pub(crate) headers: Vec<(String, String)>, + pub(crate) body: Vec, + } + + impl RawResponse { + pub(crate) fn status_code(&self) -> u16 { + self.status_line + .split(' ') + .nth(1) + .and_then(|c| c.parse().ok()) + .expect("status code") + } + pub(crate) fn header_values<'a>(&'a self, name: &'a str) -> impl Iterator { + header_values(&self.headers, name) + } + } + + /// Send verbatim request bytes and read the full framed response. + pub(crate) async fn raw_exchange(port: u16, request: &[u8]) -> RawResponse { + tokio::time::timeout(std::time::Duration::from_secs(10), async { + let stream = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .unwrap(); + let mut wire = Wire::new(stream); + wire.write_all(request).await; + let head = wire.read_until(b"\r\n\r\n").await; + let (status_line, headers) = parse_head(&head); + let body = + if header_values(&headers, "transfer-encoding").any(|v| v.contains("chunked")) { + wire.read_chunked().await + } else if let Some(cl) = header_values(&headers, "content-length").next() { + wire.read_n(cl.parse().expect("content-length integer")) + .await + } else { + wire.read_to_eof().await + }; + RawResponse { + status_line, + headers, + body, + } + }) + .await + .expect("exchange timed out") + } + + /// Bind `127.0.0.1:0` synchronously (usable before the runtime schedules + /// the accept loop) and spawn one task per accepted connection. + pub(crate) fn spawn_raw_listener(handler: F) -> u16 + where + F: Fn(Wire) -> Fut + Send + Sync + 'static, + Fut: std::future::Future + Send + 'static, + { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let port = listener.local_addr().unwrap().port(); + let handler = Arc::new(handler); + tokio::spawn(async move { + let listener = tokio::net::TcpListener::from_std(listener).unwrap(); + loop { + let (stream, _) = listener.accept().await.unwrap(); + let h = Arc::clone(&handler); + tokio::spawn(async move { h(Wire::new(stream)).await }); + } + }); + port + } + + /// Spawn the REAL proxy router (production state constructor) on an + /// ephemeral loopback port, gated by `token`. + pub(crate) async fn spawn_proxy(token: &str) -> u16 { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let port = listener.local_addr().unwrap().port(); + let app = router(ProxyState::new(Arc::new(token.to_string()))); + tokio::spawn(async move { + let listener = tokio::net::TcpListener::from_std(listener).unwrap(); + axum::serve(listener, app.into_make_service()) + .await + .unwrap(); + }); + port + } + + /// Spawn an arbitrary standalone axum router on an ephemeral port. + pub(crate) async fn spawn_app(app: Router) -> u16 { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let listener = tokio::net::TcpListener::from_std(listener).unwrap(); + axum::serve(listener, app.into_make_service()) + .await + .unwrap(); + }); + port + } + + /// Static client→proxy head for `path_and_query`. `connection: close` + /// gives the response a deterministic EOF. Ends with the blank line that + /// terminates the head. Callers append body bytes for body-bearing + /// requests (with a matching `content-length` or chunked framing added to + /// the head) and pass extra request header lines (`"name: value"`). + pub(crate) fn proxy_head( + port: u16, + token: &str, + method: &str, + upstream_port: u16, + path_and_query: &str, + extra_headers: &[&str], + ) -> Vec { + let extras: String = extra_headers.iter().map(|h| format!("{h}\r\n")).collect(); + format!( + "{method} /api/proxy/http/{upstream_port}{path_and_query} HTTP/1.1\r\n\ + host: 127.0.0.1:{port}\r\n\ + x-auth-token: {token}\r\n\ + {extras}\ + connection: close\r\n\r\n", + ) + .into_bytes() + } + + /// Spawn a capture-all upstream plus the proxy, returning + /// (proxy_port, upstream_port, captured). + pub(crate) async fn spawn_proxy_and_capture( + token: &str, + ) -> (u16, u16, Arc>>) { + let captured = Arc::new(tokio::sync::Mutex::new(Vec::new())); + let cap = Arc::clone(&captured); + let upstream_port = spawn_raw_listener(move |mut wire| { + let cap = Arc::clone(&cap); + async move { + let req = read_request(&mut wire).await; + cap.lock().await.push(req); + wire.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok") + .await; + } + }); + let proxy_port = spawn_proxy(token).await; + (proxy_port, upstream_port, captured) + } +} + +#[cfg(test)] +mod lb_probes { + use super::wire_support::*; + use super::*; + + // ── L0 sanity: a plain GET passes through the proxy at all ──────────── + #[tokio::test] + async fn l0_sanity_plain_get_through_proxy() { + let upstream_port = spawn_raw_listener(move |mut wire| async move { + let _req = read_request(&mut wire).await; + wire.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 5\r\n\r\nhello") + .await; + }); + let proxy_port = spawn_proxy(TEST_TOKEN).await; + let resp = raw_exchange( + proxy_port, + &proxy_head(proxy_port, TEST_TOKEN, "GET", upstream_port, "/", &[]), + ) + .await; + assert_eq!(resp.status_code(), 200); + assert_eq!(resp.body, b"hello"); + } + + // ── L1: axum matches percent-ENCODED paths and `uri.path()` is raw ───── + // + // The G1 fix parses ``/`` off the raw URI instead of the + // decoded `Path` catch-all. Load-bearing: if axum rejected encoded + // paths at routing time or handed the handler a decoded URI, the fix would + // have to move elsewhere entirely. + #[tokio::test] + async fn l1_route_matches_encoded_path_and_uri_path_is_raw() { + let app = Router::new().route( + "/api/proxy/http/{*tail}", + any(|uri: Uri| async move { uri.path().to_string() }), + ); + let port = spawn_app(app).await; + let resp = raw_exchange( + port, + b"GET /api/proxy/http/5173/a%2Fb/c%20d?q=%2F HTTP/1.1\r\nhost: x\r\nconnection: close\r\n\r\n", + ) + .await; + assert_eq!(resp.status_code(), 200, "route must match an encoded path"); + assert_eq!( + String::from_utf8(resp.body).unwrap(), + "/api/proxy/http/5173/a%2Fb/c%20d", + "uri.path() must be the RAW, undecoded path" + ); + } + + // ── L2: `HeaderMap::insert` collapses; `append` preserves ────────────── + // + // Confirms G2's mechanism: the current proxy copies headers with + // `insert`, which REPLACES all existing values (multi `Set-Cookie` dies). + #[test] + fn l2_headermap_insert_collapses_append_preserves() { + let mut collapsed = HeaderMap::new(); + collapsed.insert( + HeaderName::from_static("x-dupe"), + HeaderValue::from_static("one"), + ); + collapsed.insert( + HeaderName::from_static("x-dupe"), + HeaderValue::from_static("two"), + ); + assert_eq!( + collapsed.get_all("x-dupe").iter().count(), + 1, + "insert REPLACES every existing value for the name" + ); + + let mut preserved = HeaderMap::new(); + preserved.append( + HeaderName::from_static("x-dupe"), + HeaderValue::from_static("one"), + ); + preserved.append( + HeaderName::from_static("x-dupe"), + HeaderValue::from_static("two"), + ); + let values: Vec<_> = preserved + .get_all("x-dupe") + .iter() + .map(|v| v.to_str().unwrap()) + .collect(); + assert_eq!(values, ["one", "two"], "append preserves order + values"); + } + + // ── L3: `Bytes` extraction is DefaultBodyLimit-capped; `Body` is not ──── + // + // The G3 fix streams `axum::body::Body` instead of buffering `Bytes`. + // Load-bearing: confirms the observed 413 mechanism and that extracting + // `Body` directly really sidesteps the limit (no extra layer needed). + #[tokio::test] + async fn l3_bytes_extractor_hits_default_body_limit_body_extractor_does_not() { + let big = vec![b'x'; 3 * 1024 * 1024]; + + let bytes_app = Router::new().route( + "/t", + any(|body: axum::body::Bytes| async move { body.len().to_string() }), + ); + let port = spawn_app(bytes_app).await; + let mut req = format!( + "POST /t HTTP/1.1\r\nhost: x\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + big.len() + ) + .into_bytes(); + req.extend_from_slice(&big); + let resp = raw_exchange(port, &req).await; + assert_eq!( + resp.status_code(), + 413, + "Bytes extraction must hit axum's 2 MiB DefaultBodyLimit" + ); + + let body_app = Router::new().route( + "/t", + any(|body: Body| async move { + let bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap(); + bytes.len().to_string() + }), + ); + let port = spawn_app(body_app).await; + let resp = raw_exchange(port, &req).await; + assert_eq!(resp.status_code(), 200, "Body extraction is uncapped"); + assert_eq!(String::from_utf8(resp.body).unwrap(), big.len().to_string()); + } + + // ── L4: reqwest injects NO accept-encoding and performs NO decompression ─ + // + // The proxy forwards whatever the client sent (gzip included) byte-exact. + // Load-bearing: if reqwest auto-added `Accept-Encoding` or transparently + // decompressed, forwarded `content-encoding` + body bytes would disagree. + // (Cargo already shows `default-features=false` without compression + // features; this probe proves the runtime behavior, not the manifest.) + #[tokio::test] + async fn l4_reqwest_no_accept_encoding_injection_and_no_decompression() { + // Real gzip bytes of "proxied-gzip-payload-0123456789" (gzip -n). + const GZIP: [u8; 51] = [ + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x2b, 0x28, 0xca, 0xaf, + 0xc8, 0x4c, 0x4d, 0xd1, 0x4d, 0xaf, 0xca, 0x2c, 0xd0, 0x2d, 0x48, 0xac, 0xcc, 0xc9, + 0x4f, 0x4c, 0xd1, 0x35, 0x30, 0x34, 0x32, 0x36, 0x31, 0x35, 0x33, 0xb7, 0xb0, 0x04, + 0x00, 0x0d, 0xae, 0xc4, 0xd7, 0x1f, 0x00, 0x00, 0x00, + ]; + let captured = Arc::new(tokio::sync::Mutex::new(Vec::new())); + let cap = Arc::clone(&captured); + let upstream_port = spawn_raw_listener(move |mut wire| { + let cap = Arc::clone(&cap); + async move { + let req = read_request(&mut wire).await; + cap.lock().await.push(req); + let mut resp = + b"HTTP/1.1 200 OK\r\ncontent-encoding: gzip\r\ncontent-length: 51\r\n\r\n" + .to_vec(); + resp.extend_from_slice(&GZIP); + wire.write_all(&resp).await; + } + }); + let proxy_port = spawn_proxy(TEST_TOKEN).await; + // NOTE: the client deliberately sends NO accept-encoding. + let resp = raw_exchange( + proxy_port, + &proxy_head(proxy_port, TEST_TOKEN, "GET", upstream_port, "/", &[]), + ) + .await; + assert_eq!(resp.status_code(), 200); + let got = captured.lock().await; + assert_eq!(got.len(), 1); + assert_eq!( + got[0].header_values("accept-encoding").count(), + 0, + "reqwest must NOT inject accept-encoding; got {:?}", + got[0].headers + ); + assert_eq!( + resp.header_values("content-encoding").collect::>(), + vec!["gzip"], + "content-encoding header must survive" + ); + assert_eq!( + resp.body, GZIP, + "gzip body bytes must pass through untouched (no decompression)" + ); + } + + // ── L5: reqwest honors an explicit content-length with a streamed body ── + // + // The G3 fix streams the incoming body via `Body::wrap_stream` while + // forwarding the client's original `content-length` (legacy parity). If + // reqwest overrode or ignored the explicit header, swe would fall back to + // chunked (still spec-legal, but a parity wobble worth knowing upfront). + #[tokio::test] + async fn l5_wrap_stream_honors_explicit_content_length() { + let captured = Arc::new(tokio::sync::Mutex::new(Vec::new())); + let cap = Arc::clone(&captured); + let upstream_port = spawn_raw_listener(move |mut wire| { + let cap = Arc::clone(&cap); + async move { + let req = read_request(&mut wire).await; + cap.lock().await.push(req); + wire.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok") + .await; + } + }); + + let stream = futures_util::stream::iter(vec![Ok::<_, std::io::Error>( + axum::body::Bytes::from_static(b"hello world"), + )]); + let client = reqwest::Client::new(); + let resp = client + .post(format!("http://127.0.0.1:{upstream_port}/x")) + .header("content-length", "11") + .body(reqwest::Body::wrap_stream(stream)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let got = captured.lock().await; + assert_eq!(got.len(), 1); + assert_eq!( + got[0].header_values("content-length").collect::>(), + vec!["11"], + "explicit content-length must reach the wire; got {:?}", + got[0].headers + ); + assert_eq!( + got[0].header_values("transfer-encoding").count(), + 0, + "no chunked framing when content-length is known" + ); + assert_eq!(got[0].body, b"hello world"); + } +} + +// ── Socket-level contract tests (BROWSER-01) ─────────────────────────────── +// +// The durable parity contract of `proxy-router.ts`'s HTTP half, driven end +// to end through the REAL proxy router on real loopback sockets +// (`wire_support`). Each test name states the legacy behavior being pinned. +#[cfg(test)] +mod socket_contract { + use super::wire_support::*; + use super::*; + + const CONTRACT_TOKEN: &str = "contract-token-0123456789abcdef"; + + /// G2 response direction: multi-`Set-Cookie` (and any other repeated + /// header) must survive the proxy in wire order — the header copy must + /// APPEND, never collapse. Legacy: Node forwards header arrays verbatim + /// (`proxy-router.ts:35` + `res.writeHead` with a `set-cookie: string[]`). + /// This is what keeps proxied apps' session/login flows alive. + #[tokio::test] + async fn response_preserves_duplicate_set_cookie_headers_in_order() { + let upstream_port = spawn_raw_listener(move |mut wire| async move { + let _req = read_request(&mut wire).await; + wire.write_all( + b"HTTP/1.1 200 OK\r\n\ + content-length: 2\r\n\ + set-cookie: session=abc; Path=/; HttpOnly\r\n\ + set-cookie: prefs=dark; Path=/\r\n\ + x-dupe-marker: first\r\n\ + x-dupe-marker: second\r\n\r\n\ + ok", + ) + .await; + }); + let proxy_port = spawn_proxy(CONTRACT_TOKEN).await; + let resp = raw_exchange( + proxy_port, + &proxy_head(proxy_port, CONTRACT_TOKEN, "GET", upstream_port, "/", &[]), + ) + .await; + assert_eq!(resp.status_code(), 200); + let cookies: Vec<_> = resp.header_values("set-cookie").collect(); + assert_eq!( + cookies, + vec!["session=abc; Path=/; HttpOnly", "prefs=dark; Path=/"], + "BOTH set-cookie headers must survive in wire order (legacy forwards arrays)" + ); + let dupes: Vec<_> = resp.header_values("x-dupe-marker").collect(); + assert_eq!(dupes, vec!["first", "second"]); + } + + /// G2 request direction: a repeated client header must reach upstream + /// as two headers, in wire order. Legacy passes `req.headers` arrays + /// through to `http.request` untouched. + #[tokio::test] + async fn request_forwards_duplicate_headers_in_order() { + let captured = Arc::new(tokio::sync::Mutex::new(Vec::new())); + let cap = Arc::clone(&captured); + let upstream_port = spawn_raw_listener(move |mut wire| { + let cap = Arc::clone(&cap); + async move { + let req = read_request(&mut wire).await; + cap.lock().await.push(req); + wire.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok") + .await; + } + }); + let proxy_port = spawn_proxy(CONTRACT_TOKEN).await; + let resp = raw_exchange( + proxy_port, + format!( + "GET /api/proxy/http/{upstream_port}/ HTTP/1.1\r\n\ + host: 127.0.0.1:{proxy_port}\r\n\ + x-auth-token: {CONTRACT_TOKEN}\r\n\ + x-dupe-request: alpha\r\n\ + x-dupe-request: beta\r\n\ + connection: close\r\n\r\n" + ) + .as_bytes(), + ) + .await; + assert_eq!(resp.status_code(), 200); + let got = captured.lock().await; + assert_eq!(got.len(), 1); + let dupes: Vec<_> = got[0].header_values("x-dupe-request").collect(); + assert_eq!( + dupes, + vec!["alpha", "beta"], + "a repeated client header must reach upstream twice, in wire order" + ); + } +} + +#[cfg(test)] +mod socket_contract_g1 { + use super::wire_support::*; + + const TOKEN: &str = "contract-token-0123456789abcdef"; + + /// G1: the upstream request-target must be `[] [? ]` with + /// EVERY byte the client sent — legacy forwards Node's RAW `req.url` + /// (`proxy-router.ts:99`), which is never percent-decoded. A dev server + /// routing on encoded segments (Vite's `/@fs/%2F...`, file names with + /// `%20`/`%25`) MUST see its real route, not a decoded-and-re-encoded + /// mutation. + #[tokio::test] + async fn path_and_query_reach_upstream_byte_exact_never_decoded() { + let cases: &[&str] = &[ + // Percent-encoded slash must NOT become a path separator. + "/a%2Fb/c", + // Encoded space round-trips as %20, not a literal space. + "/hello%20world.txt", + // Encoded percent must not collapse. + "/100%25-certain", + // UTF-8 encoded bytes stay encoded exactly as sent. + "/caf%C3%A9/na%C3%AFve", + // Encoded '?' stays data, not a query boundary. + "/x%3Fy/z", + // Encoded query values pass through untranslated. + "/search?q=a%2Fb&r=%20&n=1%2B1", + // '?' + raw '+' in query: no form-decoding games. + "/plus?a=1+2&b=x+y", + // Repeated and empty query keys, order preserved. + "/multi?a=1&a=2&empty=&b=3", + // Deep path with trailing slash. + "/assets/vendor/%40scope/pkg/dist/", + // Bare root forms. + "/", + "", + ]; + for path_and_query in cases { + let (proxy_port, upstream_port, captured) = spawn_proxy_and_capture(TOKEN).await; + let resp = raw_exchange( + proxy_port, + format!( + "GET /api/proxy/http/{upstream_port}{path_and_query} HTTP/1.1\r\n\ + host: 127.0.0.1:{proxy_port}\r\n\ + x-auth-token: {TOKEN}\r\n\ + connection: close\r\n\r\n" + ) + .as_bytes(), + ) + .await; + assert_eq!(resp.status_code(), 200, "case {path_and_query:?}"); + let got = captured.lock().await; + assert_eq!(got.len(), 1, "case {path_and_query:?}"); + let expected = if path_and_query.is_empty() { + "/" + } else { + *path_and_query + }; + assert_eq!( + got[0].raw_target(), + expected, + "upstream must receive the byte-exact raw path+query (legacy: raw req.url)" + ); + } + } +} + +#[cfg(test)] +mod socket_contract_g3 { + use super::wire_support::*; + use std::sync::Arc; + + const TOKEN: &str = "contract-token-0123456789abcdef"; + + /// G3 (capacity + framing): a multi-MiB body must stream through — legacy + /// pipes the raw request stream with NO size ceiling (`req.pipe(proxyReq)`, + /// `proxy-router.ts:119-120`; only the pre-proxy `express.json` 1MB cap + /// touches JSON). The original `content-length` rides along (legacy strips + /// only host/connection/transfer-encoding), so upstream never sees a + /// re-framed chunked upload when the client declared a length. + #[tokio::test] + async fn large_body_streams_through_with_original_content_length() { + let (proxy_port, upstream_port, captured) = spawn_proxy_and_capture(TOKEN).await; + let big: Vec = (0..(3 * 1024 * 1024u32)).map(|i| (i % 251) as u8).collect(); + let mut request = format!( + "POST /api/proxy/http/{upstream_port}/upload HTTP/1.1\r\n\ + host: 127.0.0.1:{proxy_port}\r\n\ + x-auth-token: {TOKEN}\r\n\ + content-type: application/octet-stream\r\n\ + content-length: {}\r\n\ + connection: close\r\n\r\n", + big.len() + ) + .into_bytes(); + request.extend_from_slice(&big); + let resp = raw_exchange(proxy_port, &request).await; + assert_eq!(resp.status_code(), 200, "3 MiB upload must not 413"); + let got = captured.lock().await; + assert_eq!(got.len(), 1); + assert_eq!( + got[0].header_values("content-length").collect::>(), + vec![big.len().to_string()], + "upstream must see the ORIGINAL content-length, not a re-framed body" + ); + assert_eq!( + got[0].header_values("transfer-encoding").count(), + 0, + "no chunked re-framing when the client declared a length" + ); + assert_eq!(got[0].body.len(), big.len(), "byte count preserved"); + assert!(got[0].body == big, "every byte preserved"); + } + + /// G3 (incrementality): the first body chunk must be observable UPSTREAM + /// before the client has sent the last chunk — i.e. the proxy streams the + /// request body instead of fully buffering it. Signal-gated both ways: + /// zero wall-clock sleeps; pre-fix this deadlocks into the read deadline + /// (the buffered extractor waits for a complete body the client is + /// holding back), post-fix it flows. + #[tokio::test] + async fn request_body_chunks_arrive_upstream_incrementally() { + let (seen_tx, seen_rx) = tokio::sync::oneshot::channel::>(); + let seen_tx = Arc::new(tokio::sync::Mutex::new(Some(seen_tx))); + let full_body = Arc::new(tokio::sync::Mutex::new(Vec::new())); + let fb = Arc::clone(&full_body); + let upstream_port = spawn_raw_listener(move |mut wire| { + let seen_tx = Arc::clone(&seen_tx); + let fb = Arc::clone(&fb); + async move { + let _head = wire.read_until(b"\r\n\r\n").await; + // First chunk only (frame: "5\r\nhello\r\n"). + let size_line = wire.read_until(b"\r\n").await; + assert_eq!(String::from_utf8_lossy(&size_line).trim(), "5"); + let first = wire.read_n(5).await; + let crlf = wire.read_n(2).await; + assert_eq!(crlf, b"\r\n"); + // Prove arrival BEFORE the client's final chunk leaves. + if let Some(tx) = seen_tx.lock().await.take() { + let _ = tx.send(first.clone()); + } + let mut body = first; + // Remaining chunks through the terminator. + body.extend_from_slice(&wire.read_chunked().await); + fb.lock().await.extend_from_slice(&body); + wire.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok") + .await; + } + }); + let proxy_port = spawn_proxy(TOKEN).await; + + tokio::time::timeout(std::time::Duration::from_secs(10), async move { + let stream = tokio::net::TcpStream::connect(("127.0.0.1", proxy_port)) + .await + .unwrap(); + let mut wire = Wire::new(stream); + let head = format!( + "POST /api/proxy/http/{upstream_port}/stream-upload HTTP/1.1\r\n\ + host: 127.0.0.1:{proxy_port}\r\n\ + x-auth-token: {TOKEN}\r\n\ + content-type: text/plain\r\n\ + transfer-encoding: chunked\r\n\ + connection: close\r\n\r\n" + ); + wire.write_all(head.as_bytes()).await; + wire.write_all(b"5\r\nhello\r\n").await; + + // The upstream MUST observe the first chunk while the client is + // still holding the second one back. + let first = seen_rx.await.expect("upstream must see the first chunk"); + assert_eq!(first, b"hello"); + + wire.write_all(b"5\r\nworld\r\n0\r\n\r\n").await; + + let resp_head = wire.read_until(b"\r\n\r\n").await; + let (status_line, headers) = parse_head(&resp_head); + assert_eq!(status_line, "HTTP/1.1 200 OK"); + // Framing-agnostic body drain (content-length forwarding is G4). + let _body = + if header_values(&headers, "transfer-encoding").any(|v| v.contains("chunked")) { + wire.read_chunked().await + } else if let Some(cl) = header_values(&headers, "content-length").next() { + wire.read_n(cl.parse().unwrap()).await + } else { + wire.read_to_eof().await + }; + }) + .await + .expect("request body must stream (deadlock = full buffering)"); + + assert_eq!( + full_body.lock().await.as_slice(), + b"helloworld", + "reassembled upstream body is the full stream" + ); + } +} + +#[cfg(test)] +mod socket_contract_g4 { + use super::wire_support::*; + + const TOKEN: &str = "contract-token-0123456789abcdef"; + + /// G4: `content-length` is NOT an iframe-blocking header — the removal set + /// is exactly `{x-frame-options, content-security-policy, + /// content-security-policy-report-only}` (`proxy-router.ts:19-23`), and + /// legacy's `writeHead(status, strippedHeaders)` forwards everything else, + /// length included. Because reqwest runs with zero compression features, + /// response bytes are bit-exact (probe L4), so the advertised length stays + /// truthful. hyper re-frames ONLY the genuinely hop-by-hop headers + /// (`connection`, `transfer-encoding`, `keep-alive`). + #[tokio::test] + async fn response_forwards_content_length_and_every_non_blocking_header() { + let upstream_port = spawn_raw_listener(move |mut wire| async move { + let _req = read_request(&mut wire).await; + wire.write_all( + b"HTTP/1.1 200 OK\r\n\ + content-type: text/plain; charset=utf-8\r\n\ + content-length: 11\r\n\ + etag: \"v1-abc\"\r\n\ + cache-control: no-store\r\n\ + x-custom-upstream: keep-me\r\n\ + vary: accept-encoding\r\n\ + x-frame-options: DENY\r\n\ + content-security-policy: frame-ancestors 'none'\r\n\ + content-security-policy-report-only: default-src 'self'\r\n\r\n\ + hello world", + ) + .await; + }); + let proxy_port = spawn_proxy(TOKEN).await; + let resp = raw_exchange( + proxy_port, + &proxy_head(proxy_port, TOKEN, "GET", upstream_port, "/page", &[]), + ) + .await; + assert_eq!(resp.status_code(), 200); + assert_eq!(resp.body, b"hello world"); + assert_eq!( + resp.header_values("content-length").collect::>(), + vec!["11"], + "content-length must survive — it is not an iframe-blocking header" + ); + assert_eq!( + resp.header_values("etag").collect::>(), + vec!["\"v1-abc\""] + ); + assert_eq!( + resp.header_values("cache-control").collect::>(), + vec!["no-store"] + ); + assert_eq!( + resp.header_values("x-custom-upstream").collect::>(), + vec!["keep-me"] + ); + assert_eq!( + resp.header_values("vary").collect::>(), + vec!["accept-encoding"] + ); + assert_eq!( + resp.header_values("content-type").collect::>(), + vec!["text/plain; charset=utf-8"] + ); + // The removal set is EXACTLY these three — nothing more. + assert_eq!(resp.header_values("x-frame-options").count(), 0); + assert_eq!(resp.header_values("content-security-policy").count(), 0); + assert_eq!( + resp.header_values("content-security-policy-report-only") + .count(), + 0 + ); + } +} + +#[cfg(test)] +mod socket_contract_rest { + use super::wire_support::*; + use std::sync::Arc; + + const TOKEN: &str = "contract-token-0123456789abcdef"; + + async fn one_free_closed_port() -> u16 { + // Bind then drop: nobody is listening at this port afterwards. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + port + } + + /// Every HTTP method reaches upstream verbatim (`any(proxy)` mirrors + /// Express's method-agnostic `router.use`). + #[tokio::test] + async fn all_methods_forward_verbatim() { + for method in ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] { + let (proxy_port, upstream_port, captured) = spawn_proxy_and_capture(TOKEN).await; + let resp = raw_exchange( + proxy_port, + &proxy_head(proxy_port, TOKEN, method, upstream_port, "/m", &[]), + ) + .await; + assert_eq!(resp.status_code(), 200, "method {method}"); + let got = captured.lock().await; + assert_eq!(got.len(), 1, "method {method}"); + assert!( + got[0].request_line.starts_with(&format!("{method} /m ")), + "method {method} must reach upstream verbatim: {:?}", + got[0].request_line + ); + } + } + + /// Upstream statuses pass through untouched, including a 302 whose + /// `location` header must reach the iframe (redirects are NEVER followed + /// — Node's raw `http.request` follows none either). + #[tokio::test] + async fn statuses_and_redirect_location_forward_verbatim() { + let cases: &[(&[u8], u16)] = &[ + ( + b"HTTP/1.1 201 Created\r\ncontent-length: 7\r\n\r\ncreated", + 201, + ), + ( + b"HTTP/1.1 302 Found\r\nlocation: /target?x=%2F&y=1\r\ncontent-length: 0\r\n\r\n", + 302, + ), + ( + b"HTTP/1.1 404 Not Found\r\ncontent-length: 7\r\n\r\nmissing", + 404, + ), + ( + b"HTTP/1.1 418 I'm a Teapot\r\ncontent-length: 6\r\n\r\nteapot", + 418, + ), + ]; + for (response_bytes, expected_status) in cases { + let response_bytes = response_bytes.to_vec(); + let upstream_port = spawn_raw_listener(move |mut wire| { + let response_bytes = response_bytes.clone(); + async move { + let _req = read_request(&mut wire).await; + wire.write_all(&response_bytes).await; + } + }); + let proxy_port = spawn_proxy(TOKEN).await; + let resp = raw_exchange( + proxy_port, + &proxy_head(proxy_port, TOKEN, "GET", upstream_port, "/s", &[]), + ) + .await; + assert_eq!(resp.status_code(), *expected_status); + if *expected_status == 302 { + assert_eq!( + resp.header_values("location").collect::>(), + vec!["/target?x=%2F&y=1"], + "the iframe must see the real 302 target (never followed)" + ); + } + } + } + + /// Error shapes stay byte-compatible with legacy: + /// `502 {"error":"Failed to connect to localhost:"}`, + /// `400 {"error":"Invalid port number"}`, + /// `401 {"error":"Unauthorized"}`. + #[tokio::test] + async fn error_shapes_match_legacy() { + let proxy_port = spawn_proxy(TOKEN).await; + + // 502: port with nobody listening. + let closed = one_free_closed_port().await; + let resp = raw_exchange( + proxy_port, + &proxy_head(proxy_port, TOKEN, "GET", closed, "/x", &[]), + ) + .await; + assert_eq!(resp.status_code(), 502); + assert_eq!( + String::from_utf8(resp.body).unwrap(), + format!("{{\"error\":\"Failed to connect to localhost:{closed}\"}}") + ); + + // 400: out-of-range and non-numeric ports. + for bad in ["0", "65536", "abc", "-1", "80x"] { + let resp = raw_exchange( + proxy_port, + format!( + "GET /api/proxy/http/{bad}/x HTTP/1.1\r\n\ + host: 127.0.0.1:{proxy_port}\r\n\ + x-auth-token: {TOKEN}\r\n\ + connection: close\r\n\r\n" + ) + .as_bytes(), + ) + .await; + assert_eq!(resp.status_code(), 400, "port {bad:?}"); + assert_eq!( + String::from_utf8(resp.body).unwrap(), + "{\"error\":\"Invalid port number\"}" + ); + } + + // 401: missing and wrong credentials. + for auth_line in ["", "x-auth-token: wrong-token\r\n"] { + let resp = raw_exchange( + proxy_port, + format!( + "GET /api/proxy/http/{closed}/x HTTP/1.1\r\n\ + host: 127.0.0.1:{proxy_port}\r\n\ + {auth_line}\ + connection: close\r\n\r\n" + ) + .as_bytes(), + ) + .await; + assert_eq!(resp.status_code(), 401, "auth line {auth_line:?}"); + assert_eq!( + String::from_utf8(resp.body).unwrap(), + "{\"error\":\"Unauthorized\"}" + ); + } + } + + /// Auth via the `freshell-auth` cookie (the browser pane's iframe path — + /// `buildHttpProxyUrl` keeps requests same-origin, so the cookie rides). + #[tokio::test] + async fn cookie_auth_accepted() { + let (proxy_port, upstream_port, _) = spawn_proxy_and_capture(TOKEN).await; + let resp = raw_exchange( + proxy_port, + format!( + "GET /api/proxy/http/{upstream_port}/ HTTP/1.1\r\n\ + host: 127.0.0.1:{proxy_port}\r\n\ + cookie: freshell-auth={TOKEN}\r\n\ + connection: close\r\n\r\n" + ) + .as_bytes(), + ) + .await; + assert_eq!(resp.status_code(), 200); + } + + /// Useful request headers pass through; `host` is rewritten to the + /// loopback target; hop-by-hop framing headers are dropped — + /// `proxy-router.ts:90-93` plus the wrap-review r3 security strip on + /// BOTH servers: the gate's own credentials (`x-auth-token` header, + /// the `freshell-auth` cookie pair) are withheld from upstream while + /// an app's own cookies (`session=live` here, mingled in ONE cookie + /// header with the auth pair) still flow. + #[tokio::test] + async fn useful_request_headers_pass_host_rewritten_framing_dropped() { + let (proxy_port, upstream_port, captured) = spawn_proxy_and_capture(TOKEN).await; + let resp = raw_exchange( + proxy_port, + &proxy_head( + proxy_port, + TOKEN, + "GET", + upstream_port, + "/h", + &[ + "cookie: freshell-auth=the-gate-token; session=live; theme=dark", + "authorization: Bearer abc123", + "user-agent: FreshellE2E/1.0", + "accept: text/html, application/xhtml+xml", + "accept-encoding: gzip", + "x-custom-request: yes-please", + "referer: http://127.0.0.1:9/inside", + ], + ), + ) + .await; + assert_eq!(resp.status_code(), 200); + let got = captured.lock().await; + assert_eq!(got.len(), 1); + let req = &got[0]; + assert_eq!( + req.header_values("host").collect::>(), + vec![format!("127.0.0.1:{upstream_port}")], + "host is rewritten to the loopback target exactly" + ); + assert_eq!( + req.header_values("cookie").collect::>(), + vec!["session=live; theme=dark"], + "the app's cookies survive; ONLY the freshell-auth pair is withheld" + ); + assert_eq!( + req.header_values("authorization").collect::>(), + vec!["Bearer abc123"] + ); + assert_eq!( + req.header_values("user-agent").collect::>(), + vec!["FreshellE2E/1.0"] + ); + assert_eq!( + req.header_values("accept").collect::>(), + vec!["text/html, application/xhtml+xml"] + ); + assert_eq!( + req.header_values("accept-encoding").collect::>(), + vec!["gzip"], + "the client's accept-encoding forwards untouched (passthrough, L4)" + ); + assert_eq!( + req.header_values("x-custom-request").collect::>(), + vec!["yes-please"] + ); + assert_eq!( + req.header_values("referer").collect::>(), + vec!["http://127.0.0.1:9/inside"] + ); + assert_eq!( + req.header_values("connection").count(), + 0, + "hop-by-hop dropped" + ); + assert_eq!( + req.header_values("keep-alive").count(), + 0, + "hop-by-hop dropped" + ); + // The gate's own credential must NEVER reach the upstream (the + // wrap-review r3 strip — the original legacy leak is patched on + // both servers). + assert_eq!( + req.header_values("x-auth-token").count(), + 0, + "x-auth-token is withheld from upstream" + ); + } + + /// A request carrying ONLY the auth cookie (the browser pane's iframe + /// navigation shape: the browser attaches `freshell-auth` because the + /// iframe is same-origin) arrives at the upstream with NO cookie header + /// at all — not even an empty residue. + #[tokio::test] + async fn auth_only_cookie_is_dropped_entirely_upstream() { + let (proxy_port, upstream_port, captured) = spawn_proxy_and_capture(TOKEN).await; + let resp = raw_exchange( + proxy_port, + &proxy_head( + proxy_port, + TOKEN, + "GET", + upstream_port, + "/only-auth", + &["cookie: freshell-auth=the-gate-token"], + ), + ) + .await; + assert_eq!(resp.status_code(), 200); + let got = captured.lock().await; + assert_eq!(got.len(), 1); + assert_eq!( + got[0].header_values("cookie").count(), + 0, + "a cookie header carrying only freshell-auth must not reach upstream" + ); + assert_eq!( + got[0].header_values("x-auth-token").count(), + 0, + "the gate header never reaches upstream" + ); + } + + /// Response streaming: the first chunk must be observable at the CLIENT + /// while the upstream is still holding the second back — the response is a + /// live pipe, not a filled buffer. Signal-gated both directions; zero + /// wall-clock sleeps. + #[tokio::test] + async fn response_streams_chunk_by_chunk_incrementally() { + let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>(); + let release_rx = Arc::new(tokio::sync::Mutex::new(Some(release_rx))); + let upstream_port = spawn_raw_listener(move |mut wire| { + let release_rx = Arc::clone(&release_rx); + async move { + let _req = read_request(&mut wire).await; + wire.write_all( + b"HTTP/1.1 200 OK\r\n\ + content-type: text/event-stream\r\n\ + transfer-encoding: chunked\r\n\r\n\ + 5\r\nhello\r\n", + ) + .await; + // Hold the second chunk until the test proves the first one + // already arrived at the client. + if let Some(rx) = release_rx.lock().await.take() { + let _ = rx.await; + } + wire.write_all(b"5\r\nworld\r\n0\r\n\r\n").await; + } + }); + let proxy_port = spawn_proxy(TOKEN).await; + + tokio::time::timeout(std::time::Duration::from_secs(10), async move { + let stream = tokio::net::TcpStream::connect(("127.0.0.1", proxy_port)) + .await + .unwrap(); + let mut wire = Wire::new(stream); + wire.write_all(&proxy_head( + proxy_port, + TOKEN, + "GET", + upstream_port, + "/events", + &[], + )) + .await; + let head = wire.read_until(b"\r\n\r\n").await; + let (status_line, _headers) = parse_head(&head); + assert_eq!(status_line, "HTTP/1.1 200 OK"); + // First chunk ONLY (frame: "5\r\nhello\r\n"). + let size_line = wire.read_until(b"\r\n").await; + assert_eq!(String::from_utf8_lossy(&size_line).trim(), "5"); + let first = wire.read_n(5).await; + assert_eq!( + first, b"hello", + "first chunk arrives while upstream holds the rest" + ); + let crlf = wire.read_n(2).await; + assert_eq!(crlf, b"\r\n"); + // Release the upstream's second chunk; the full body reassembles. + release_tx.send(()).unwrap(); + let rest = wire.read_chunked().await; + assert_eq!(rest, b"world"); + }) + .await + .expect("response must stream (deadlock = buffered response)"); + } + + /// HEAD: status + headers forward; the (declared-but-absent) body must not + /// hang the client. Upstream follows HEAD semantics (no body bytes). + #[tokio::test] + async fn head_request_forwards_headers_without_body_hang() { + let (proxy_port, upstream_port, captured) = spawn_proxy_and_capture(TOKEN).await; + tokio::time::timeout(std::time::Duration::from_secs(10), async move { + let stream = tokio::net::TcpStream::connect(("127.0.0.1", proxy_port)) + .await + .unwrap(); + let mut wire = Wire::new(stream); + wire.write_all(&proxy_head( + proxy_port, + TOKEN, + "HEAD", + upstream_port, + "/h", + &[], + )) + .await; + let head = wire.read_until(b"\r\n\r\n").await; + let (status_line, headers) = parse_head(&head); + assert_eq!(status_line, "HTTP/1.1 200 OK", "HEAD status forwards"); + assert_eq!( + header_values(&headers, "content-length").collect::>(), + vec!["2"], + "HEAD content-length forwards" + ); + let body = wire.read_to_eof().await; + assert_eq!(body, b"", "HEAD never carries a body"); + }) + .await + .expect("HEAD response must complete (not hang waiting for a body)"); + let got = captured.lock().await; + assert_eq!(got.len(), 1); + assert!(got[0].request_line.starts_with("HEAD /h ")); + } + + /// Bodies forward BYTE-EXACT — including pretty-printed JSON whitespace + /// legacy would have re-serialized (deliberate, recorded divergence: + /// strictly stronger body preservation) and arbitrary binary. + #[tokio::test] + async fn bodies_forward_byte_exact() { + let pretty_json = "{\n \"key\": \"v\u{00e8}lue\",\n \"n\": 1\n}".as_bytes(); + let binary: Vec = (0u16..=255).map(|b| b as u8).collect(); + for (label, body) in [("pretty-json", pretty_json.to_vec()), ("binary", binary)] { + let (proxy_port, upstream_port, captured) = spawn_proxy_and_capture(TOKEN).await; + let mut request = format!( + "POST /api/proxy/http/{upstream_port}/echo HTTP/1.1\r\n\ + host: 127.0.0.1:{proxy_port}\r\n\ + x-auth-token: {TOKEN}\r\n\ + content-length: {}\r\n\ + connection: close\r\n\r\n", + body.len() + ) + .into_bytes(); + request.extend_from_slice(&body); + let resp = raw_exchange(proxy_port, &request).await; + assert_eq!(resp.status_code(), 200, "{label}"); + let got = captured.lock().await; + assert_eq!(got.len(), 1, "{label}"); + assert_eq!(got[0].body, body, "{label} must arrive byte-exact"); + } + } +} diff --git a/crates/freshell-server/src/rate_limit.rs b/crates/freshell-server/src/rate_limit.rs index d9c542ad1..0ce73dca1 100644 --- a/crates/freshell-server/src/rate_limit.rs +++ b/crates/freshell-server/src/rate_limit.rs @@ -72,6 +72,21 @@ pub trait Clock: Send + Sync { fn now_ms(&self) -> u64; } +/// HARNESS-14: a `Clock` reading the shared, env-gated test clock +/// (`freshell_platform::clock`). Only ever installed when the clock is +/// gate-ON (a `FRESHELL_TEST_CLOCK=1` test boot — see `main.rs`'s limiter +/// construction), so a spec can advance past the refill window without +/// wall-clock sleeps (SAFE-02 window tests). Epoch ms as `u64` — only +/// deltas matter to the limiter, so the absolute base is irrelevant. +#[derive(Debug, Clone, Copy)] +pub struct GlobalTestClock; + +impl Clock for GlobalTestClock { + fn now_ms(&self) -> u64 { + freshell_platform::clock::now_ms().max(0) as u64 + } +} + /// Production clock: wraps a monotonic [`std::time::Instant`] captured at /// construction, so `now_ms()` is `elapsed()` since boot of this limiter -- /// immune to system-clock adjustments (NTP steps, DST), matching the @@ -205,6 +220,17 @@ impl RateLimiter { Arc::new(Self::new(Box::new(SystemClock::new()), config)) } + /// HARNESS-14 gate-aware constructor: the shared test clock when a + /// `FRESHELL_TEST_CLOCK=1` test boot enabled it, otherwise the exact + /// [`SystemClock`] production has always used (never default-on). + pub fn new_gate_aware(config: RateLimitConfig) -> Arc { + if freshell_platform::clock::enabled() { + Arc::new(Self::new(Box::new(GlobalTestClock), config)) + } else { + Self::new_system(config) + } + } + /// Attempt to consume one token. `Ok(())` means the caller may proceed; /// `Err(retry_after_secs)` means the bucket is empty, and the caller /// should surface a 429 with a `Retry-After` header of that many @@ -435,6 +461,48 @@ mod tests { ); } + /// HARNESS-14: `new_gate_aware` binds the shared test clock when the + /// env gate is on (test override stands in for `FRESHELL_TEST_CLOCK=1`), + /// so refill math follows virtual `advance_ms` instead of wall sleeps. + #[test] + fn new_gate_aware_refills_on_the_shared_test_clock_when_enabled() { + let _guard = crate::test_clock_gate::TestClockGate::enable(); + freshell_platform::clock::freeze().unwrap(); + let limiter = RateLimiter::new_gate_aware(RateLimitConfig { + capacity: 1.0, + refill_per_sec: 1.0, + }); + assert!(limiter.try_acquire().is_ok(), "bucket starts full"); + assert!( + limiter.try_acquire().is_err(), + "frozen time: the emptied bucket never refills on real time" + ); + std::thread::sleep(std::time::Duration::from_millis(20)); + assert!(limiter.try_acquire().is_err(), "still frozen — no refill"); + freshell_platform::clock::advance_ms(1_001).unwrap(); + assert!( + limiter.try_acquire().is_ok(), + "a virtual step past 1/refill_per_sec must refill one token" + ); + } + + /// The gate-off half: without the gate, `new_gate_aware` is exactly the + /// production `SystemClock` construction (real elapsed time refills). + #[test] + fn new_gate_aware_without_the_gate_uses_system_time() { + let _guard = crate::test_clock_gate::TestClockGate::locked(false); + let limiter = RateLimiter::new_gate_aware(RateLimitConfig { + capacity: 1.0, + refill_per_sec: 1_000.0, + }); + assert!(limiter.try_acquire().is_ok()); + std::thread::sleep(std::time::Duration::from_millis(3)); + assert!( + limiter.try_acquire().is_ok(), + "at 1000 tokens/sec, a few real milliseconds refill the bucket" + ); + } + // --- axum middleware integration tests ------------------------------- async fn probe_app(limiter: Arc) -> Router { diff --git a/crates/freshell-server/src/session_directory.rs b/crates/freshell-server/src/session_directory.rs index 9dea77805..843f98c89 100644 --- a/crates/freshell-server/src/session_directory.rs +++ b/crates/freshell-server/src/session_directory.rs @@ -430,7 +430,21 @@ async fn session_directory( let identities = state.identity.list(); let items = join_live_terminals(items, &identities); match apply_query(items, &query, &identities) { - Ok(page) => Json(page).into_response(), + Ok(mut page) => { + // SESSION-05 (project colors, read half): embed the config's + // `projectColors` map on the page when non-empty — the channel + // the shared client's refetch-after-`sessions.changed` reads to + // overlay each project group's header color + // (`shared/read-models.ts` + // `SessionDirectoryPageSchema.projectColors`; legacy mirror: + // `server/session-directory/service.ts`). Omitted entirely when + // empty, matching the legacy service's conditional assignment. + let colors = state.settings.project_colors(); + if !colors.is_empty() { + page["projectColors"] = Value::Object(colors); + } + Json(page).into_response() + } // Bad cursor → 400, matching `querySessionDirectory`'s `/cursor/i` → 400. Err(msg) => ( axum::http::StatusCode::BAD_REQUEST, @@ -2474,6 +2488,134 @@ mod tests { std::fs::remove_dir_all(&home).ok(); } + /// SESSION-05 (project colors, read half): the session-directory PAGE + /// embeds the config's `projectColors` map verbatim (only when + /// non-empty) so the shared client's refetch-after-`sessions.changed` + /// can overlay each project group's color + /// (`shared/read-models.ts` `SessionDirectoryPageSchema.projectColors`; + /// legacy mirror: `server/session-directory/service.ts`). + #[tokio::test] + async fn session_directory_page_embeds_config_project_colors() { + use axum::http::Request; + use tower::ServiceExt; + + let home = claude_home_with(&["real-corrupted.jsonl"]); + // The fixture's every session carries + // `cwd: D:\Users\Dan\GoogleDrivePersonal\code\freshell`, which is + // also its projectPath (see b_t7 above). + std::fs::create_dir_all(home.join(".freshell")).unwrap(); + std::fs::write( + home.join(".freshell").join("config.json"), + serde_json::to_string(&json!({ + "version": 1, + "settings": {}, + "sessionOverrides": {}, + "terminalOverrides": {}, + "projectColors": { + "D:\\Users\\Dan\\GoogleDrivePersonal\\code\\freshell": "#ff8800", + "/some/unrelated/path": "#112233" + } + })) + .unwrap(), + ) + .unwrap(); + let settings = + crate::settings_store::SettingsStore::load(Some(&home), vec!["claude".into()]); + let auth_token: std::sync::Arc = std::sync::Arc::new("tok".into()); + let session_index = + std::sync::Arc::new(SessionIndex::new(vec![ + std::sync::Arc::new(ClaudeSource::new(claude_home(&home))) + as std::sync::Arc, + ])); + let state = SessionDirectoryState { + auth_token: std::sync::Arc::clone(&auth_token), + settings, + session_index: Some(session_index), + identity: freshell_ws::identity::TerminalIdentityRegistry::new(), + metadata: crate::session_metadata::SessionMetadataStore::new(home.join(".freshell")), + }; + let app = router(state); + let resp = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/session-directory?priority=visible&includeNonInteractive=1") + .header("x-auth-token", "tok") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let page: Value = serde_json::from_slice(&bytes).unwrap(); + // The WHOLE map rides the page (unrelated path included): the + // client overlays per-project, and a color for a project not in + // THIS page is needed by the page it does appear on. + assert_eq!( + page["projectColors"]["D:\\Users\\Dan\\GoogleDrivePersonal\\code\\freshell"], + json!("#ff8800"), + "the fetched page must carry the project color for header rendering" + ); + assert_eq!( + page["projectColors"]["/some/unrelated/path"], + json!("#112233"), + "unrelated colors are carried verbatim (unchanged by this fetch)" + ); + std::fs::remove_dir_all(&home).ok(); + } + + /// SESSION-05: with NO configured colors the page must NOT gain a + /// `projectColors` key — the field is optional in the wire schema and + /// stays absent (matching the legacy service, which omits an empty + /// map). + #[tokio::test] + async fn session_directory_page_omits_project_colors_key_when_empty() { + use axum::http::Request; + use tower::ServiceExt; + + let home = claude_home_with(&["real-corrupted.jsonl"]); + let settings = + crate::settings_store::SettingsStore::load(Some(&home), vec!["claude".into()]); + let auth_token: std::sync::Arc = std::sync::Arc::new("tok".into()); + let session_index = + std::sync::Arc::new(SessionIndex::new(vec![ + std::sync::Arc::new(ClaudeSource::new(claude_home(&home))) + as std::sync::Arc, + ])); + let state = SessionDirectoryState { + auth_token: std::sync::Arc::clone(&auth_token), + settings, + session_index: Some(session_index), + identity: freshell_ws::identity::TerminalIdentityRegistry::new(), + metadata: crate::session_metadata::SessionMetadataStore::new(home.join(".freshell")), + }; + let app = router(state); + let resp = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/session-directory?priority=visible&includeNonInteractive=1") + .header("x-auth-token", "tok") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let page: Value = serde_json::from_slice(&bytes).unwrap(); + assert!( + page.get("projectColors").is_none(), + "an empty colors map must not appear on the wire: {page:?}" + ); + std::fs::remove_dir_all(&home).ok(); + } + /// B-T8: no home (`session_index: None`) still yields an empty page -- /// the prior "no home resolvable" behavior, now expressed as an absent /// index instead of an absent `home: Option`. diff --git a/crates/freshell-server/src/settings_store.rs b/crates/freshell-server/src/settings_store.rs index e3d736319..e67c20776 100644 --- a/crates/freshell-server/src/settings_store.rs +++ b/crates/freshell-server/src/settings_store.rs @@ -77,6 +77,18 @@ pub struct SettingsStore { session_overrides_dirty: Arc>>, /// The `terminal_overrides` analog of `session_overrides_dirty`. terminal_overrides_dirty: Arc>>, + /// `config.projectColors` (`config-store.ts:66, 549-562`): per-project + /// path → CSS color string map the `PUT /api/project-colors` route + /// writes (legacy `setProjectColor`) and the session-directory read + /// model embeds in each page (legacy `getProjectColors`). std `Mutex` + /// (not tokio) so the sync `persist` path can snapshot it (same as the + /// override maps above). + project_colors: Arc>>, + /// The `project_colors` analog of `session_overrides_dirty`: color + /// keys written via [`SettingsStore::set_project_color`] THIS boot + /// always win over disk; keys never touched defer to disk (side-by-side + /// bake-in: the legacy Node server writing the same `config.json`). + project_colors_dirty: Arc>>, /// Throttled mtime-check state backing the freshness reload /// (`maybe_reload_overrides`) on the override READ path /// (`session_overrides()`/`terminal_overrides()`). @@ -100,6 +112,14 @@ pub struct SettingsStore { /// fields, no heap data) -- matches the struct's own "Cheap to clone" /// contract, so no `Arc` wrapper is needed. config_fallback: Option, + /// CFG-04: the boot-extracted/merged `legacyLocalSettingsSeed` + /// (`crate::legacy_local_seed`), served ONLY via `/api/bootstrap` + /// (`boot.rs`) and written to (or removed from) `config.json` on every + /// persist. Computed once during [`SettingsStore::load`] and never + /// mutated afterwards — mirroring the legacy `ConfigStore`'s cached copy + /// (`config-store.ts:337-347,459-462`) — so it needs no lock and is + /// cloned out per request, like `config_fallback`. + legacy_local_settings_seed: Option, } /// Throttle + change-detection state for [`SettingsStore::maybe_reload_overrides`]. @@ -177,6 +197,19 @@ impl SettingsStore { } let mut settings = load_full_settings(home); + // CFG-04: extract + merge the legacy local-settings seed from the raw + // document (`config-store.ts#loadInternal`: local-only keys move out + // of `settings` into a top-level `legacyLocalSettingsSeed`; a stored + // seed wins on conflict but merges with freshly-extracted strays). + // This runs AFTER `maybe_restore_config_from_backup`, so the read + // below sees the same recovered document as every other tolerant + // loader. `seed_normalization_persist` is the seed-scoped half of the + // original's `shouldPersistNormalizedConfig` (config-store.ts:364-366): + // true when local keys were stripped out of `settings`, or the merged + // seed differs from the raw stored key (incl. garbage → removal). + let (legacy_local_settings_seed, seed_normalization_persist) = + load_legacy_local_settings_seed(home); + // (1) Legacy default-enabled migration (`settings-migrate.ts:17-49`). let mut migrated_legacy = false; { @@ -246,6 +279,7 @@ impl SettingsStore { let codex_display_id_secret = load_or_mint_codex_display_id_secret(home); let terminal_overrides = load_terminal_overrides(home); let session_overrides = load_session_overrides(home); + let project_colors = load_project_colors(home); let store = Self { inner: Arc::new(RwLock::new(settings.clone())), home: home.map(|p| Arc::new(p.to_path_buf())), @@ -253,16 +287,19 @@ impl SettingsStore { codex_display_id_secret: Arc::new(codex_display_id_secret), terminal_overrides: Arc::new(std::sync::Mutex::new(terminal_overrides)), session_overrides: Arc::new(std::sync::Mutex::new(session_overrides)), + project_colors: Arc::new(std::sync::Mutex::new(project_colors)), // Nothing is dirty yet at boot -- every key we just loaded came // straight from disk, so it defers to disk until THIS process // actually patches it. session_overrides_dirty: Arc::new(std::sync::Mutex::new(Default::default())), terminal_overrides_dirty: Arc::new(std::sync::Mutex::new(Default::default())), + project_colors_dirty: Arc::new(std::sync::Mutex::new(Default::default())), overrides_reload_state: Arc::new(std::sync::Mutex::new(Default::default())), reload_throttle_window: std::time::Duration::from_secs(1), config_fallback, + legacy_local_settings_seed, }; - if needs_persist { + if needs_persist || seed_normalization_persist { // GAP2 legacy parity (`config-store.ts:367-374`): a failed // BOOT-time normalization/seed-migration persist logs a warning // and keeps running on the in-memory value -- there is no HTTP @@ -285,6 +322,20 @@ impl SettingsStore { self.inner.read().await.clone() } + /// CFG-12: share the ONE live settings tree by lock so the `/ws` + /// connect handshake (`freshell_ws::WsState::handshake_settings` → + /// `build_handshake_with_capabilities`) resolves CURRENT values on every + /// connection — the original's per-connection `handshakeSnapshotProvider` + /// (`server/index.ts:415-427` awaits `configStore.getSettings()`; the + /// frame goes out via `ws-handler.ts:1815-1845`). Because this vends THIS + /// store's inner lock (never a copy), a value committed by [`Self::patch`] + /// is precisely what the next (re)connecting client's `settings.updated` + /// carries — closing the boot-frozen-snapshot gap behind the CFG-12 e2e + /// red (a PATCHed `defaultCwd` never reached a second browser context). + pub fn shared_settings_lock(&self) -> Arc> { + Arc::clone(&self.inner) + } + /// The enabled coding-CLI provider names (`settings.codingCli.enabledProviders`) /// — the resolve route's unsearched-provider computation and snapshot /// provider gate read this (`resolve.rs`). Async because the settings @@ -303,6 +354,17 @@ impl SettingsStore { self.config_fallback.clone() } + /// CFG-04: the boot-extracted `legacyLocalSettingsSeed` + /// (`config-store.ts#getLegacyLocalSettingsSeed`, `config-store.ts:459-462`). + /// Served ONLY by `/api/bootstrap` — the seed is a bootstrap-time + /// migration bridge for fresh browser/WebView profiles, never part of the + /// live settings tree, `/api/settings`, or any WS message. Immutable after + /// boot (the legacy store likewise never mutates it post-load), so this is + /// a plain clone-out accessor. + pub fn legacy_local_settings_seed(&self) -> Option { + self.legacy_local_settings_seed.clone() + } + /// Deep-merge `patch_body` into the live settings (R1: same handler for /// PUT and PATCH), persist to `config.json` (R2), and return the merged /// tree. `Err` carries the `(status, body)` to answer with on a validation @@ -319,16 +381,33 @@ impl SettingsStore { )); } } - if let Some(details) = validate_patch(patch_body, &self.valid_cli_providers) { + // The legacy strip/validate/merge stages all operate on private mutable + // copies of the request body (`{...value}` spreads in + // `stripDeprecatedSettingsPatchAliases`, `normalizeSettingsPatch`), + // never the caller's object \u2014 mirror that with one clone up front. + let mut patch_body = patch_body.clone(); + // SESSION-13: `stripDeprecatedSettingsPatchAliases` + // (`settings-router.ts:23-36`) runs BEFORE the strict schema, so a + // sidebar carrying only the deprecated alias is a no-op, not an + // unrecognized-key rejection. + strip_deprecated_settings_patch_aliases(&mut patch_body); + if let Some(details) = validate_patch(&patch_body, &self.valid_cli_providers) { return Err(( StatusCode::BAD_REQUEST, json!({ "error": "Invalid request", "details": details }), )); } + // SESSION-13: legacy zod's `z.coerce.boolean()` (validation stage) and + // `mergeServerSettings`' `normalizeTrimmedStringList` (merge stage) + // both fire before the base tree is touched; normalizing the private + // patch copy here is observationally identical, since a PRESENT + // sidebar key always REPLACES the base value (`hasOwn` semantics, + // `shared/settings.ts:1276-1285`). + normalize_sidebar_patch(&mut patch_body); let guard = self.inner.write().await; let mut value = serde_json::to_value(&*guard).unwrap_or_else(|_| json!({})); - deep_merge(&mut value, patch_body); + deep_merge(&mut value, &patch_body); // NOTE: `knownProviders` is regular patchable, persisted state in the // original (pinned live 2026-07-12: PATCH `{codingCli:{knownProviders: // ["claude"]}}` replaces and persists it); names are validated against @@ -445,6 +524,20 @@ impl SettingsStore { "settings".to_string(), serde_json::to_value(settings).unwrap_or_else(|_| json!({})), ); + // CFG-04 owned key: the boot-extracted `legacyLocalSettingsSeed`. + // Written from memory when present; REMOVED when `None` — JS parity: + // the legacy config object carries `legacyLocalSettingsSeed: + // undefined` in that case, and `JSON.stringify` omits `undefined` + // object members, so "absent" (never "null") is the legacy on-disk + // shape. + match &self.legacy_local_settings_seed { + Some(seed) => { + map.insert("legacyLocalSettingsSeed".to_string(), seed.clone()); + } + None => { + map.remove("legacyLocalSettingsSeed"); + } + } // ADOPT-FROM-DISK MERGE (Batch B hardening): fresh disk read, // overlaid with ONLY the keys this process marked dirty. A key @@ -492,6 +585,29 @@ impl SettingsStore { Value::Object(merged_terminal_overrides), ); + // `projectColors` gets the SAME adopt-from-disk + dirty-overlay + // treatment (SESSION-05): fresh disk read overlaid with only the + // color keys THIS process wrote this boot. Pre-SESSION-05 this key + // fell through to the passthrough below (`map.entry(...)`), which + // preserved it but could never accept a Rust-originated write. + let disk_project_colors = map + .get("projectColors") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let merged_project_colors = { + let memory = self.project_colors.lock().expect("project colors lock"); + let dirty = self + .project_colors_dirty + .lock() + .expect("project colors dirty lock"); + overlay_dirty_keys(disk_project_colors, &memory, &dirty) + }; + map.insert( + "projectColors".to_string(), + Value::Object(merged_project_colors), + ); + // `serverSecrets` is overlaid onto whatever was already there (not // replaced wholesale), so a sibling secret this store doesn't know // about would survive too. @@ -507,10 +623,12 @@ impl SettingsStore { map.insert("serverSecrets".to_string(), Value::Object(secrets)); // Everything else -- `completedMigrations`, `recentDirectories`, - // `projectColors`, any unrecognized top-level key -- is left exactly - // as loaded above. Only seed the original's first-write defaults - // when truly absent (`config-store.ts:356-360`). - map.entry("projectColors").or_insert_with(|| json!({})); + // any unrecognized top-level key -- is left exactly as loaded + // above. Only seed the original's first-write defaults when truly + // absent (`config-store.ts:356-360`). (`projectColors` moved out of + // this passthrough into the adopt-from-disk overlay above for + // SESSION-05; the seed-default-if-absent effect is preserved there: + // a fresh disk read of a missing key starts from empty.) map.entry("recentDirectories").or_insert_with(|| json!([])); let text = serde_json::to_string_pretty(&doc) @@ -584,6 +702,16 @@ impl SettingsStore { return; } + let disk_colors = load_project_colors(Some(home)); + { + let mut memory = self.project_colors.lock().expect("project colors lock"); + let dirty = self + .project_colors_dirty + .lock() + .expect("project colors dirty lock"); + *memory = overlay_dirty_keys(disk_colors, &memory, &dirty); + } + let disk_session = load_session_overrides(Some(home)); let mut memory = self .session_overrides @@ -867,6 +995,96 @@ impl SettingsStore { } next } + + /// A snapshot of `config.projectColors` (the `PUT /api/project-colors` + /// route writes it; the session-directory read model embeds it in each + /// page — `getProjectColors`, `config-store.ts:561-563`). Same + /// mtime-checked freshness reload as the override maps (`freshness + /// reload` above), so a bake-in partner's color write shows up on the + /// next read without a restart. + pub fn project_colors(&self) -> serde_json::Map { + self.maybe_reload_overrides(); + self.project_colors + .lock() + .expect("project colors lock") + .clone() + } + + /// `configStore.setProjectColor(projectPath, color)` + /// (`config-store.ts:549-558`): `projectColors = {...cfg.projectColors, + /// [projectPath]: color}` then save. Additive (other paths preserved), + /// overwrites an existing path's color, persists the whole config + /// atomically, and marks the path dirty for the boot (side-by-side: + /// this process's write wins over a concurrent external edit to the + /// same path; untouched paths adopt disk values — see + /// [`overlay_dirty_keys`]). + /// + /// Unlike the override patchers, the persist failure surfaces to the + /// caller: the legacy route AWAITS the save + /// (`project-colors-router.ts:24`, `await configStore.setProjectColor`) + /// before responding, so a failed write is a failed request — and an + /// axum handler can translate that error, which the original's + /// unwrapped express-4 async handler cannot do gracefully. + /// + /// FAILURE SEMANTICS (legacy parity): `ConfigStore.saveInternal` + /// assigns `this.cache = cfg` only AFTER the atomic write succeeds + /// (`config-store.ts:424-435`), so a failed persist leaves the legacy + /// in-memory map untouched. We must therefore install the new value + /// first (the persisted settings tree is derived from the in-memory + /// map via [`overlay_dirty_keys`]), but ROLL BACK on failure — + /// restoring the prior value and prior dirty-set membership so + /// `project_colors()` keeps serving the last-successfully-persisted + /// state and the freshness reload can still adopt a later external + /// disk edit (a stale dirty mark would tombstone it). The rollback + /// only fires while the installed value is still ours: a concurrent + /// same-path write that raced us is left alone (last-writer-wins, + /// exactly like two Legacy writers). Pinned by + /// `set_project_color_rolls_back_in_memory_state_when_persist_fails`. + pub async fn set_project_color(&self, path: &str, color: &str) -> std::io::Result<()> { + let (prior_value, prior_dirty) = { + let all = self.project_colors.lock().expect("project colors lock"); + let dirty = self + .project_colors_dirty + .lock() + .expect("project colors dirty lock"); + (all.get(path).cloned(), dirty.contains(path)) + }; + { + let mut all = self.project_colors.lock().expect("project colors lock"); + all.insert(path.to_string(), json!(color)); + self.project_colors_dirty + .lock() + .expect("project colors dirty lock") + .insert(path.to_string()); + } + let settings = self.get().await; + match self.persist(&settings) { + Ok(()) => Ok(()), + Err(err) => { + let mut all = self.project_colors.lock().expect("project colors lock"); + if all.get(path) == Some(&json!(color)) { + match prior_value { + Some(value) => { + all.insert(path.to_string(), value); + } + None => { + all.remove(path); + } + } + let mut dirty = self + .project_colors_dirty + .lock() + .expect("project colors dirty lock"); + if prior_dirty { + dirty.insert(path.to_string()); + } else { + dirty.remove(path); + } + } + Err(err) + } + } + } } /// Advisory cross-process serialization for `persist()`'s read-modify-write @@ -1334,6 +1552,36 @@ fn load_terminal_overrides(home: Option<&Path>) -> serde_json::Map/.freshell/config.json` +/// (tolerant: any read/parse error or a non-object field degrades to +/// empty — matching the original's load normalization +/// `projectColors: existing.projectColors || {}`, `config-store.ts:358`, +/// and `readConfigFile`'s tolerance). Entries with NON-STRING values are +/// dropped on load (normalization, not a disk rewrite — the file keeps its +/// junk until the next persist): the wire schema and the client both model +/// the map as string-valued (`z.record(z.string(), z.string())` in +/// `shared/read-models.ts`, `typeof` checks in `normalizeProjects`), so a +/// hand-edited junk entry must not flow to the session-directory page. +fn load_project_colors(home: Option<&Path>) -> serde_json::Map { + let Some(home) = home else { + return serde_json::Map::new(); + }; + let config_path = home.join(".freshell").join("config.json"); + let Ok(text) = std::fs::read_to_string(&config_path) else { + return serde_json::Map::new(); + }; + let Ok(doc) = serde_json::from_str::(&text) else { + return serde_json::Map::new(); + }; + let Some(obj) = doc.get("projectColors").and_then(Value::as_object) else { + return serde_json::Map::new(); + }; + obj.iter() + .filter(|(_, v)| v.is_string()) + .map(|(k, v)| (k.clone(), v.clone())) + .collect() +} + /// Load `config.sessionOverrides` from `/.freshell/config.json` (tolerant: /// any read/parse error or non-object degrades to empty, matching /// `config-store.ts#readConfigFile`). @@ -1379,6 +1627,48 @@ fn load_full_settings(home: Option<&Path>) -> ServerSettings { serde_json::from_value(merged).unwrap_or(defaults) } +/// CFG-04: replicate the seed half of `config-store.ts#loadInternal`. Reads +/// the raw `config.json` once (tolerantly — any read/parse failure degrades to +/// "no seed", like every other loader in this module) and returns: +/// +/// * the seed itself: `extractLegacyLocalSettingsSeed(rawSettings)` merged +/// with the stored top-level key via `mergeLocalSettings(extracted, +/// stored)`-when-stored semantics (`stored` wins on conflict; +/// `config-store.ts:333-339`). A non-object stored key counts as absent for +/// the merge but still schedules a normalization persist below, matching the +/// original's raw-vs-normalized `JSON.stringify` comparison; +/// * whether the seed machinery requires the boot normalization persist: +/// `extracted.is_some()` (local keys were inside `settings`, which the typed +/// `ServerSettings` round-trip strips on the next write — the original's +/// first `shouldPersistNormalizedConfig` clause, seed-scoped) OR the merged +/// seed differs from the RAW stored key (`config-store.ts:366`), including +/// `Some`↔`None` transitions (garbage or un-normalizable stored content gets +/// dropped from disk). +fn load_legacy_local_settings_seed(home: Option<&Path>) -> (Option, bool) { + let Some(home) = home else { + return (None, false); + }; + let config_path = home.join(".freshell").join("config.json"); + let Ok(text) = std::fs::read_to_string(&config_path) else { + return (None, false); + }; + let Ok(doc) = serde_json::from_str::(&text) else { + return (None, false); + }; + + let extracted = doc + .get("settings") + .and_then(crate::legacy_local_seed::extract_legacy_local_settings_seed); + let stored_raw = doc.get("legacyLocalSettingsSeed"); + let stored = stored_raw + .filter(|v| v.is_object()) + .and_then(crate::legacy_local_seed::extract_legacy_local_settings_seed); + let merged = crate::legacy_local_seed::merge_legacy_seeds(extracted.as_ref(), stored.as_ref()); + + let seed_changed = merged.as_ref() != stored_raw; + (merged, extracted.is_some() || seed_changed) +} + /// Read an existing `serverSecrets.codexDisplayIdSecret` from `config.json` /// (so a restart keeps the SAME secret, matching the original's persisted /// config-store semantics), else mint a fresh one. Never fails: an @@ -1452,6 +1742,12 @@ fn validate_patch(patch: &Value, valid_cli_providers: &[String]) -> Option Option) { + let Value::Object(sb) = sidebar else { + issues.push(invalid_type_issue("object", &json!(["sidebar"]), sidebar)); + return; + }; + if let Some(subs) = sb.get("excludeFirstChatSubstrings") { + match subs { + Value::Array(items) => { + for (i, item) in items.iter().enumerate() { + if !item.is_string() { + issues.push(invalid_type_issue( + "string", + &json!(["sidebar", "excludeFirstChatSubstrings", i]), + item, + )); + } + } + } + other => issues.push(invalid_type_issue( + "array", + &json!(["sidebar", "excludeFirstChatSubstrings"]), + other, + )), + } + } + const SIDEBAR_KEYS: &[&str] = &[ + "excludeFirstChatSubstrings", + "excludeFirstChatMustStart", + "autoGenerateTitles", + ]; + let unknown: Vec<&str> = sb + .keys() + .map(String::as_str) + .filter(|k| !SIDEBAR_KEYS.contains(k)) + .collect(); + if !unknown.is_empty() { + issues.push(unrecognized_keys_issue(&unknown, &json!(["sidebar"]))); + } +} + +/// SESSION-13: `stripDeprecatedSettingsPatchAliases` +/// (`server/settings-router.ts:23-36`) — the `sidebar` alias +/// `ignoreCodexSubagentSessions` (browser-local since the settings split, +/// CFG-04/CFG-12) is deleted BEFORE the strict schema runs on the legacy +/// PATCH path; this port runs it before [`validate_patch`]. +fn strip_deprecated_settings_patch_aliases(patch: &mut Value) { + if let Some(sb) = patch.get_mut("sidebar").and_then(Value::as_object_mut) { + sb.remove("ignoreCodexSubagentSessions"); + } +} + +/// SESSION-13: the value normalization of the legacy sidebar PATCH write +/// path, applied to the private patch copy before `deep_merge`: +/// +/// * `excludeFirstChatMustStart` / `autoGenerateTitles` — legacy +/// `z.coerce.boolean()` coerces at schema time (before merge), via JS +/// `Boolean()` truthiness: `""`, `0`, `null` are false; every non-empty +/// string (even `"false"`), non-zero number, array, and object is true. +/// * `excludeFirstChatSubstrings` — `mergeServerSettings` runs +/// `normalizeTrimmedStringList` (`shared/string-list.ts`): keep strings, +/// trim, drop empties, dedupe first-occurrence-wins. The string filter is +/// dead code here post-validation (all elements are strings) but kept as +/// the `sanitizeServerSettingsPatch` mirror. +fn normalize_sidebar_patch(patch: &mut Value) { + let Some(sb) = patch.get_mut("sidebar").and_then(Value::as_object_mut) else { + return; + }; + for key in ["excludeFirstChatMustStart", "autoGenerateTitles"] { + if let Some(v) = sb.get_mut(key) { + let coerced = js_truthiness(v); + *v = Value::Bool(coerced); + } + } + if let Some(slot) = sb.get_mut("excludeFirstChatSubstrings") { + if let Value::Array(items) = slot { + let strings: Vec = items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect(); + let normalized = normalize_trimmed_string_list(&strings); + *slot = json!(normalized); + } + } +} + +/// JS `Boolean(x)` — the coercion `z.coerce.boolean()` applies +/// (`shared/settings.ts:798-799`). +fn js_truthiness(v: &Value) -> bool { + match v { + Value::Null => false, + Value::Bool(b) => *b, + // JSON has no NaN/-0 distinctness: `Boolean(0)`/`Boolean(-0)` are the + // only false numbers; every other number coerces true. + Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0), + Value::String(s) => !s.is_empty(), + // `Boolean([])` and `Boolean({})` are both true in JS. + Value::Array(_) | Value::Object(_) => true, + } +} + /// The `codingCli` sub-schema (`enabledProviders`, `knownProviders`, /// `providers` record keys, then its own strict unknown-key issue) \u2014 issue /// shapes byte-matched live (M2/M5/E1\u2013E5). @@ -2446,6 +2851,242 @@ mod tests { .replace([':', '.', ' '], "-") } + // ── CFG-04: legacyLocalSettingsSeed ───────────────────────────────────── + + /// A pre-settings-split legacy `config.json`: browser-local preferences + /// still live INSIDE `settings` (theme/scale/terminal font/sidebar + /// presentation/sound — the five categories CFG-04 names), alongside the + /// server-backed `sidebar.excludeFirstChat*` knobs (SESSION-13's surface, + /// which must NOT move). + fn write_legacy_mixed_config(dir: &Path) { + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + std::fs::write( + dir.join(".freshell").join("config.json"), + r#"{ + "version": 1, + "settings": { + "network": { "configured": true, "host": "127.0.0.1" }, + "theme": "light", + "uiScale": 1.25, + "terminal": { "scrollback": 4000, "fontSize": 18, "fontFamily": "Fira Code" }, + "sidebar": { + "excludeFirstChatSubstrings": ["welcome"], + "excludeFirstChatMustStart": false, + "sortMode": "project", + "width": 280, + "collapsed": true + }, + "notifications": { "soundEnabled": false } + } +}"#, + ) + .unwrap(); + } + + /// The exact seed the legacy Node server extracts from + /// `write_legacy_mixed_config` (matches `extractLegacyLocalSettingsSeed`'s + /// real output — byte-pinned in `legacy_local_seed.rs`'s own tests). + fn expected_mixed_seed() -> Value { + json!({ + "theme": "light", + "uiScale": 1.25, + "terminal": { "fontSize": 18, "fontFamily": "Fira Code" }, + "sidebar": { "sortMode": "project", "width": 280, "collapsed": true }, + "notifications": { "soundEnabled": false } + }) + } + + fn read_disk_config(dir: &Path) -> Value { + let text = std::fs::read_to_string(dir.join(".freshell").join("config.json")).unwrap(); + serde_json::from_str(&text).unwrap() + } + + /// Boot extraction: the seed is extracted out of the legacy mixed + /// `settings`, holds all five CFG-04 categories, is stripped from the live + /// server-settings tree, and the server-backed exclusion knobs stay put. + #[tokio::test] + async fn legacy_mixed_config_seeds_and_strips_at_boot() { + let dir = std::env::temp_dir().join(format!("frs-cfg04-{}", uuid_like())); + write_legacy_mixed_config(&dir); + + let store = store_at(&dir); + assert_eq!( + store.legacy_local_settings_seed(), + Some(expected_mixed_seed()) + ); + + let live = store.get().await; + // Server-backed settings survive untouched (SESSION-13 boundary). + assert_eq!(live.terminal.scrollback, 4000); + assert_eq!( + live.sidebar.exclude_first_chat_substrings, + vec!["welcome".to_string()] + ); + assert!(!live.sidebar.exclude_first_chat_must_start); + // The live tree cannot carry local keys at all (typed struct) — the + // disk assertion below proves they were stripped, not silently kept. + std::fs::remove_dir_all(&dir).ok(); + } + + /// The boot normalization persist moves local keys out of `settings` and + /// writes the merged top-level seed, exactly like the legacy + /// `shouldPersistNormalizedConfig` re-persist. + #[tokio::test] + async fn boot_persist_strips_local_keys_and_writes_seed() { + let dir = std::env::temp_dir().join(format!("frs-cfg04-{}", uuid_like())); + write_legacy_mixed_config(&dir); + let _store = store_at(&dir); + + let disk = read_disk_config(&dir); + assert_eq!(disk["legacyLocalSettingsSeed"], expected_mixed_seed()); + let settings = disk["settings"].as_object().unwrap(); + assert!(!settings.contains_key("theme")); + assert!(!settings.contains_key("uiScale")); + assert!(!settings.contains_key("notifications")); + let terminal = settings["terminal"].as_object().unwrap(); + assert!(!terminal.contains_key("fontSize")); + assert!(!terminal.contains_key("fontFamily")); + assert_eq!(terminal["scrollback"], json!(4000)); + let sidebar = settings["sidebar"].as_object().unwrap(); + assert!(!sidebar.contains_key("sortMode")); + assert!(!sidebar.contains_key("width")); + assert!(!sidebar.contains_key("collapsed")); + assert_eq!(sidebar["excludeFirstChatSubstrings"], json!(["welcome"])); + std::fs::remove_dir_all(&dir).ok(); + } + + /// A second boot over the normalized file must rewrite NOTHING: the seed + /// change-check converges (merged == stored), and no other boot migration + /// fires — the file is byte-stable (side-by-side bake-in safety with the + /// legacy server reading the same home). + #[tokio::test] + async fn seeded_boot_is_byte_stable_on_second_boot() { + let dir = std::env::temp_dir().join(format!("frs-cfg04-{}", uuid_like())); + let discovered = vec!["claude".to_string(), "codex".to_string()]; + write_legacy_mixed_config(&dir); + let store1 = SettingsStore::load(Some(&dir), discovered.clone()); + assert_eq!( + store1.legacy_local_settings_seed(), + Some(expected_mixed_seed()) + ); + drop(store1); + let bytes1 = std::fs::read(dir.join(".freshell").join("config.json")).unwrap(); + + let store2 = SettingsStore::load(Some(&dir), discovered); + assert_eq!( + store2.legacy_local_settings_seed(), + Some(expected_mixed_seed()) + ); + drop(store2); + let bytes2 = std::fs::read(dir.join(".freshell").join("config.json")).unwrap(); + + assert_eq!( + String::from_utf8_lossy(&bytes1), + String::from_utf8_lossy(&bytes2), + "second boot rewrote the normalized config" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + /// `config-store.ts:337-339`: `stored ? mergeLocalSettings(extracted, + /// stored) : extracted` — a pre-existing top-level seed wins on conflict + /// while extracted-only sections still join the merged seed. + #[tokio::test] + async fn stored_seed_wins_over_stray_local_keys_at_boot() { + let dir = std::env::temp_dir().join(format!("frs-cfg04-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + std::fs::write( + dir.join(".freshell").join("config.json"), + r#"{ + "version": 1, + "settings": { "terminal": { "fontSize": 22 } }, + "legacyLocalSettingsSeed": { "theme": "dark" } +}"#, + ) + .unwrap(); + + let store = store_at(&dir); + assert_eq!( + store.legacy_local_settings_seed(), + Some(json!({ + "terminal": { "fontSize": 22 }, + "theme": "dark" + })) + ); + let disk = read_disk_config(&dir); + assert_eq!( + disk["legacyLocalSettingsSeed"], + json!({ "terminal": { "fontSize": 22 }, "theme": "dark" }) + ); + assert!(disk["settings"]["terminal"].get("fontSize").is_none()); + std::fs::remove_dir_all(&dir).ok(); + } + + /// Every writer keeps the seed: an unrelated PATCH must not lose the + /// seeded `legacyLocalSettingsSeed` from `config.json` (the CFG-01 + /// losslessness clause applied to this store's owned key). + #[tokio::test] + async fn seed_survives_unrelated_patch() { + let dir = std::env::temp_dir().join(format!("frs-cfg04-{}", uuid_like())); + write_legacy_mixed_config(&dir); + let store = store_at(&dir); + store + .patch(&json!({ "logging": { "debug": true } })) + .await + .expect("patch succeeds"); + + let disk = read_disk_config(&dir); + assert_eq!(disk["legacyLocalSettingsSeed"], expected_mixed_seed()); + assert_eq!(disk["settings"]["logging"]["debug"], json!(true)); + std::fs::remove_dir_all(&dir).ok(); + } + + /// Fresh install: no seed is synthesized, no seed key is ever written — + /// not at boot, not after an unrelated PATCH. + #[tokio::test] + async fn fresh_install_has_no_seed_and_never_writes_one() { + let dir = std::env::temp_dir().join(format!("frs-cfg04-{}", uuid_like())); + let store = store_at(&dir); + assert_eq!(store.legacy_local_settings_seed(), None); + store + .patch(&json!({ "logging": { "debug": true } })) + .await + .expect("patch succeeds"); + let disk = read_disk_config(&dir); + assert!( + disk.get("legacyLocalSettingsSeed").is_none(), + "unexpected seed key on disk: {disk}" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + /// A garbage stored seed (non-object, or object with nothing valid) is + /// normalized away and removed from disk at boot, exactly like the legacy + /// `JSON.stringify(existing) !== JSON.stringify(normalized)` re-persist. + #[tokio::test] + async fn garbage_stored_seed_is_dropped_at_boot() { + for raw_seed in [r#""nope""#, r#"{"theme":"neon"}"#] { + let dir = std::env::temp_dir().join(format!("frs-cfg04-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + std::fs::write( + dir.join(".freshell").join("config.json"), + format!( + r#"{{"version":1,"settings":{{"network":{{"configured":true,"host":"127.0.0.1"}}}},"legacyLocalSettingsSeed":{raw_seed}}}"# + ), + ) + .unwrap(); + + let store = store_at(&dir); + assert_eq!(store.legacy_local_settings_seed(), None, "raw: {raw_seed}"); + let disk = read_disk_config(&dir); + assert!( + disk.get("legacyLocalSettingsSeed").is_none(), + "garbage seed key survived on disk (raw: {raw_seed}): {disk}" + ); + std::fs::remove_dir_all(&dir).ok(); + } + } + /// The real acceptance for the settings model: default settings + the /// isolated-boot network overlay must serialize BYTE-FOR-BYTE to the /// `settings.updated` payload captured from the ORIGINAL node server. If the @@ -2677,9 +3318,14 @@ mod tests { /// Seeds a `config.json` shaped like a real staged incident: known /// managed keys (`settings`, overrides) PLUS keys this store never /// manages (`completedMigrations`, `recentDirectories`, a hypothetical - /// future top-level key). `store_at` is given `discovered_cli_names` and - /// a seed `codingCli.knownProviders`/`enabledProviders` that exactly - /// match, so `SettingsStore::load` does not itself trigger a persist + /// future top-level key), a stored `legacyLocalSettingsSeed` (CFG-04 + /// owned key), and BOTH the codex secret and a hypothetical sibling + /// secret this build doesn't know about. `store_at` is given + /// `discovered_cli_names` and a seed `codingCli.knownProviders`/ + /// `enabledProviders` that exactly match, and the stored seed is in the + /// exact legacy normalize assignment order (the shape + /// `seeded_boot_is_byte_stable_on_second_boot` proves merges back to + /// itself), so `SettingsStore::load` does not itself trigger a persist /// (no seed/legacy-migration path fires) -- the ONLY write in each test /// below is the one explicit patch under test. fn lossless_fixture_text() -> &'static str { @@ -2695,7 +3341,17 @@ mod tests { }, "completedMigrations": ["ai-title-shadow-cleanup"], "recentDirectories": ["/a", "/b", "/c"], - "serverSecrets": { "codexDisplayIdSecret": "seed-secret-value" }, + "serverSecrets": { + "codexDisplayIdSecret": "seed-secret-value", + "futureSiblingSecret": "sibling-sentinel-value" + }, + "legacyLocalSettingsSeed": { + "theme": "light", + "uiScale": 1.25, + "terminal": { "fontSize": 18, "fontFamily": "CFG01 Sentinel Mono" }, + "sidebar": { "sortMode": "project", "width": 280, "collapsed": true }, + "notifications": { "soundEnabled": false } + }, "zzFutureKey": { "a": 1 }, "sessionOverrides": {}, "terminalOverrides": {}, @@ -2719,6 +3375,22 @@ mod tests { json!("seed-secret-value"), "serverSecrets must round-trip" ); + assert_eq!( + cfg["serverSecrets"]["futureSiblingSecret"], + json!("sibling-sentinel-value"), + "a sibling secret this build doesn't manage must round-trip (overlaid, not rebuilt)" + ); + assert_eq!( + cfg["legacyLocalSettingsSeed"], + json!({ + "theme": "light", + "uiScale": 1.25, + "terminal": { "fontSize": 18, "fontFamily": "CFG01 Sentinel Mono" }, + "sidebar": { "sortMode": "project", "width": 280, "collapsed": true }, + "notifications": { "soundEnabled": false } + }), + "the stored legacyLocalSettingsSeed must survive every unrelated writer" + ); assert_eq!( cfg["zzFutureKey"], json!({ "a": 1 }), @@ -2758,81 +3430,755 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } - /// Same document-preservation guarantee through the terminal-override - /// persist path (`patch_terminal_override`). + /// CFG-12 RED/GREEN target (Arc identity): `shared_settings_lock()` must + /// vend the store's ONE live tree, so a PATCH-committed value is exactly + /// what the next `/ws` connection's handshake resolves + /// (`server/index.ts:415-427` per-connection `configStore.getSettings()` + /// parity). If this ever vended a copy/divergent handle, the handshake + /// would silently freeze again while every REST surface stayed live. #[tokio::test] - async fn terminal_override_patch_preserves_unmanaged_top_level_document_state() { - let dir = std::env::temp_dir().join(format!("frs-lossless-{}", uuid_like())); + async fn patch_is_visible_through_shared_settings_lock() { + let dir = std::env::temp_dir().join(format!("frs-sharedlk-{}", uuid_like())); std::fs::create_dir_all(dir.join(".freshell")).unwrap(); - std::fs::write( - dir.join(".freshell").join("config.json"), - lossless_fixture_text(), - ) - .unwrap(); let store = store_at(&dir); + let shared = store.shared_settings_lock(); + assert!(shared.read().await.default_cwd.is_none()); store - .patch_terminal_override("term-1", &[("deleted", Some(json!(true)))]) - .await; - - let cfg: Value = serde_json::from_str( - &std::fs::read_to_string(dir.join(".freshell").join("config.json")).unwrap(), - ) - .unwrap(); - assert_unmanaged_document_state_preserved(&cfg); - assert_eq!(cfg["terminalOverrides"]["term-1"]["deleted"], json!(true)); + .patch(&json!({ "defaultCwd": "/tmp/replicated-cwd" })) + .await + .unwrap(); + assert_eq!( + shared.read().await.default_cwd.as_deref(), + Some("/tmp/replicated-cwd"), + "the handshake's live source must observe the committed PATCH tree" + ); std::fs::remove_dir_all(&dir).ok(); } - /// Same document-preservation guarantee through the session-override - /// persist path (`patch_session_override`). + /// CFG-12 (restart half of the checklist validation text): a PATCHed + /// `defaultCwd` is durable -- it survives a full store reload from disk + /// (the e2e restart leg boots a second process over the same home). #[tokio::test] - async fn session_override_patch_preserves_unmanaged_top_level_document_state() { - let dir = std::env::temp_dir().join(format!("frs-lossless-{}", uuid_like())); + async fn patched_default_cwd_survives_reload_from_disk() { + let dir = std::env::temp_dir().join(format!("frs-cwdreload-{}", uuid_like())); std::fs::create_dir_all(dir.join(".freshell")).unwrap(); - std::fs::write( - dir.join(".freshell").join("config.json"), - lossless_fixture_text(), - ) - .unwrap(); let store = store_at(&dir); - store - .patch_session_override("claude:abc", &[("archived", Some(json!(true)))]) - .await; + .patch(&json!({ "defaultCwd": "/tmp/durable-cwd" })) + .await + .unwrap(); + drop(store); - let cfg: Value = serde_json::from_str( - &std::fs::read_to_string(dir.join(".freshell").join("config.json")).unwrap(), - ) - .unwrap(); - assert_unmanaged_document_state_preserved(&cfg); + let reloaded = store_at(&dir); assert_eq!( - cfg["sessionOverrides"]["claude:abc"]["archived"], - json!(true) + reloaded.get().await.default_cwd.as_deref(), + Some("/tmp/durable-cwd") ); - std::fs::remove_dir_all(&dir).ok(); } - // ── Batch B: side-by-side operation with the legacy Node server ── + // ── SESSION-13: server-wide first-chat exclusion controls ──────────── // - // These tests exercise a bake-in scenario where BOTH the Rust server - // and the legacy Node server make automatic writes to the SAME real - // `~/.freshell/config.json` (auto-titling sessionOverrides, provider - // seeding). An "external writer" below stands in for the legacy - // server: a direct `std::fs::write` to `config.json`, bypassing every - // Rust API, exactly as a concurrent process would. + // The legacy PATCH write path for the `sidebar` patch object is + // `server/settings-router.ts:127-147` (alias strip -> zod4 strict + // sidebar schema, `shared/settings.ts:796-800`) -> + // `configStore.patchSettings` -> `mergeServerSettings` + // (`shared/settings.ts:1261+`, sidebar keys at :1276-1285) with + // `normalizeTrimmedStringList` (`shared/string-list.ts`). Every + // expectation in this section was produced by executing those REAL + // legacy functions under tsx on this checkout (19-case oracle battery; + // see docs/plans/df1/SESSION-13.md for the table). + + /// Strict-object parity: an unknown `sidebar` subkey is a 400-class + /// `unrecognized_keys` issue at path `["sidebar"]`, byte-matched to zod4 + /// (legacy: `{sidebar:{bogus:1}}` -> `Unrecognized key: "bogus"`). + #[test] + fn validate_patch_sidebar_unknown_key_rejected() { + let details = validate_patch(&json!({ "sidebar": { "bogus": 1 } }), &valid5()).unwrap(); + assert_eq!(details.as_array().unwrap().len(), 1); + assert_eq!(details[0]["code"], "unrecognized_keys"); + assert_eq!(details[0]["keys"], json!(["bogus"])); + assert_eq!(details[0]["path"], json!(["sidebar"])); + assert_eq!(details[0]["message"], "Unrecognized key: \"bogus\""); + } - /// EXTERNAL-WRITER SURVIVAL: between Rust's boot-time load and a LATER - /// Rust persist (triggered by a patch to a DIFFERENT key), an external - /// writer rewrites `config.json` directly: adds a brand-new - /// `sessionOverrides` key, changes an EXISTING key Rust has never - /// touched this boot, and adds an unrelated unknown top-level key. All - /// three must survive the Rust persist, alongside Rust's own patch. - /// Pre-hardening `persist()` overlaid `session_overrides.lock().clone()` - /// onto the doc WHOLESALE, so the external addition and the external - /// edit to the untouched key would both have been silently erased -- + /// Legacy zod: `sidebar` must be an object when present — a bare string + /// is `invalid_type` expected `object` at path `["sidebar"]`. + #[test] + fn validate_patch_sidebar_not_an_object_rejected() { + let details = validate_patch(&json!({ "sidebar": "nope" }), &valid5()).unwrap(); + assert_eq!(details[0]["code"], "invalid_type"); + assert_eq!(details[0]["expected"], "object"); + assert_eq!(details[0]["path"], json!(["sidebar"])); + } + + /// Legacy zod: `excludeFirstChatSubstrings` must be an array — + /// `invalid_type` expected `array` at the full nested path. + #[test] + fn validate_patch_sidebar_substrings_wrong_type_rejected() { + let details = validate_patch( + &json!({ "sidebar": { "excludeFirstChatSubstrings": "nope" } }), + &valid5(), + ) + .unwrap(); + assert_eq!(details[0]["code"], "invalid_type"); + assert_eq!(details[0]["expected"], "array"); + assert_eq!( + details[0]["path"], + json!(["sidebar", "excludeFirstChatSubstrings"]) + ); + } + + /// Legacy zod: each substring element must be a string — the issue + /// carries the ELEMENT index in its path (`[1,"a"]` fails at index 0). + #[test] + fn validate_patch_sidebar_substring_element_type_rejected() { + let details = validate_patch( + &json!({ "sidebar": { "excludeFirstChatSubstrings": [1, "a"] } }), + &valid5(), + ) + .unwrap(); + assert_eq!(details[0]["code"], "invalid_type"); + assert_eq!(details[0]["expected"], "string"); + assert_eq!( + details[0]["path"], + json!(["sidebar", "excludeFirstChatSubstrings", 0]) + ); + } + + /// Ordering parity (`settings-router.ts:23-36` runs BEFORE the strict + /// schema): the deprecated `ignoreCodexSubagentSessions` alias is + /// stripped pre-validation, so an alias-only sidebar patch is accepted + /// as a no-op instead of tripping the strict unknown-key check, and a + /// mixed alias+unknown patch reports ONLY the unknown key (proving the + /// strip ran before the strict check, at `patch()` entry). + #[tokio::test] + async fn patch_sidebar_deprecated_alias_stripped_before_validation() { + let dir = std::env::temp_dir().join(format!("frs-s13-alias-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + let store = store_at(&dir); + + let merged = store + .patch(&json!({ "sidebar": { "ignoreCodexSubagentSessions": true } })) + .await + .expect("alias-only sidebar patch must be accepted as a no-op"); + assert!(merged.sidebar.exclude_first_chat_substrings.is_empty()); + assert!(!merged.sidebar.exclude_first_chat_must_start); + + let (_status, body) = store + .patch(&json!({ "sidebar": { "ignoreCodexSubagentSessions": true, "bogus": 1 } })) + .await + .unwrap_err(); + let details = &body["details"]; + assert_eq!(details[0]["code"], "unrecognized_keys"); + assert_eq!( + details[0]["keys"], + json!(["bogus"]), + "the alias itself must NOT appear among the rejected keys" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + /// `z.coerce.boolean()` parity: the legacy schema coerces ANY JSON value + /// for `excludeFirstChatMustStart` via JS `Boolean()` truthiness — + /// oracle table rows: "yes"→true, "false"→true, 1→true, {}→true, + /// []→true, ""→false, 0→false, null→false. + #[tokio::test] + async fn patch_coerces_exclude_first_chat_must_start_truthiness() { + for (input, expected) in [ + (json!("yes"), true), + (json!("false"), true), + (json!(1), true), + (json!({}), true), + (json!([]), true), + (json!(""), false), + (json!(0), false), + (Value::Null, false), + ] { + let dir = std::env::temp_dir().join(format!("frs-s13-coerce-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + let store = store_at(&dir); + let merged = store + .patch(&json!({ "sidebar": { "excludeFirstChatMustStart": input } })) + .await + .unwrap_or_else(|_| panic!("legacy accepts this coercion")); + assert_eq!( + merged.sidebar.exclude_first_chat_must_start, expected, + "z.coerce.boolean() truthiness parity" + ); + std::fs::remove_dir_all(&dir).ok(); + } + } + + /// `normalizeTrimmedStringList` parity (`shared/string-list.ts`): trim, + /// drop empties, dedupe first-occurrence-wins; a PRESENT key always + /// replaces (an explicit `[]` clears), an ABSENT key keeps the base. + #[tokio::test] + async fn patch_normalizes_exclude_first_chat_substrings() { + let dir = std::env::temp_dir().join(format!("frs-s13-norm-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + let store = store_at(&dir); + + store + .patch(&json!({ "sidebar": { "excludeFirstChatSubstrings": ["keep"] } })) + .await + .unwrap(); + let merged = store + .patch( + &json!({ "sidebar": { "excludeFirstChatSubstrings": [" a ", "a", "", " b ", "b"] } }), + ) + .await + .unwrap(); + assert_eq!( + merged.sidebar.exclude_first_chat_substrings, + vec!["a".to_string(), "b".to_string()] + ); + // Present-but-empty replaces (clears) — legacy `hasOwn` semantics. + let cleared = store + .patch(&json!({ "sidebar": { "excludeFirstChatSubstrings": [] } })) + .await + .unwrap(); + assert!(cleared.sidebar.exclude_first_chat_substrings.is_empty()); + // Absent key preserves the base. + let untouched = store + .patch(&json!({ "sidebar": { "autoGenerateTitles": false } })) + .await + .unwrap(); + assert!(untouched.sidebar.exclude_first_chat_substrings.is_empty()); + assert!(!untouched.sidebar.auto_generate_titles); + std::fs::remove_dir_all(&dir).ok(); + } + + /// Full-route write-through: PATCH via the REAL `patch_settings` handler + /// returns the normalized tree, persists it to `config.json`, broadcasts + /// it to connected clients in the `settings.updated` frame, and a store + /// reload (the restart leg) sees the same normalized values. + #[tokio::test] + async fn sidebar_patch_write_through_persists_normalized_disk_broadcast_and_restart() { + let dir = std::env::temp_dir().join(format!("frs-s13-route-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + // Build the router state by hand (keeping the broadcast receiver — + // `router_state_at` drops it). + let auth_token = Arc::new("tok".to_string()); + let (broadcast_tx, mut broadcast_rx) = tokio::sync::broadcast::channel::(16); + let broadcast_tx = Arc::new(broadcast_tx); + let state = SettingsRouterState { + store: store_at(&dir), + auth_token: Arc::clone(&auth_token), + broadcast_tx: Arc::clone(&broadcast_tx), + ai_key: crate::ai_title::AiKeyCell::default(), + fresh_codex: freshell_freshagent::FreshCodexState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + json!({}), + ), + registry: freshell_terminal::TerminalRegistry::new(), + }; + + let resp = patch_settings( + State(state.clone()), + authed_headers(), + Json(json!({ + "sidebar": { + "excludeFirstChatSubstrings": [" __S13AUTO__ ", "__S13AUTO__", "canary"], + "excludeFirstChatMustStart": true, + } + })), + ) + .await + .into_response(); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let body: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + body["sidebar"]["excludeFirstChatSubstrings"], + json!(["__S13AUTO__", "canary"]) + ); + assert_eq!(body["sidebar"]["excludeFirstChatMustStart"], json!(true)); + + // The broadcast frame carries the same normalized tree. + let frame = broadcast_rx.try_recv().expect("a settings.updated frame"); + let frame: Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(frame["type"], "settings.updated"); + assert_eq!( + frame["settings"]["sidebar"]["excludeFirstChatSubstrings"], + json!(["__S13AUTO__", "canary"]) + ); + assert_eq!( + frame["settings"]["sidebar"]["excludeFirstChatMustStart"], + json!(true) + ); + + // config.json on disk holds the normalized values. + let disk = read_disk_config(&dir); + assert_eq!( + disk["settings"]["sidebar"]["excludeFirstChatSubstrings"], + json!(["__S13AUTO__", "canary"]) + ); + assert_eq!( + disk["settings"]["sidebar"]["excludeFirstChatMustStart"], + json!(true) + ); + + // Restart leg: a fresh store over the same home sees them. + drop(state); + let reloaded = store_at(&dir); + let after = reloaded.get().await; + assert_eq!( + after.sidebar.exclude_first_chat_substrings, + vec!["__S13AUTO__".to_string(), "canary".to_string()] + ); + assert!(after.sidebar.exclude_first_chat_must_start); + std::fs::remove_dir_all(&dir).ok(); + } + + /// SESSION-13 replication leg at store level: a sidebar PATCH is visible + /// through the SAME shared lock the CFG-12 per-connection handshake + /// resolves (`WsState::handshake_settings`) — a freshly-connecting client + /// receives the new exclusion controls in its handshake `settings`. + #[tokio::test] + async fn sidebar_patch_visible_through_shared_settings_lock() { + let dir = std::env::temp_dir().join(format!("frs-s13-lock-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + let store = store_at(&dir); + let shared = store.shared_settings_lock(); + + assert!(shared + .read() + .await + .sidebar + .exclude_first_chat_substrings + .is_empty()); + store + .patch(&json!({ + "sidebar": { + "excludeFirstChatSubstrings": ["a"], + "excludeFirstChatMustStart": true, + } + })) + .await + .unwrap(); + let seen = shared.read().await; + assert_eq!( + seen.sidebar.exclude_first_chat_substrings, + vec!["a".to_string()], + "the handshake's live source must observe the committed sidebar PATCH" + ); + assert!(seen.sidebar.exclude_first_chat_must_start); + std::fs::remove_dir_all(&dir).ok(); + } + + /// The 19-row oracle battery, produced by executing the REAL legacy + /// `buildServerSettingsPatchSchema(...).safeParse(strip(body))` + + /// `mergeServerSettings(base, parsed.data)` under tsx on this checkout + /// (see docs/plans/df1/SESSION-13.md). Each row replays one PATCH at + /// store level and asserts identical accept/reject and an identical + /// merged sidebar (key order fixed by `SettingsSidebar`'s serde: + /// autoGenerateTitles, excludeFirstChatMustStart, excludeFirstChatSubstrings). + #[tokio::test] + async fn sidebar_patch_oracle_byte_parity_battery() { + // (patch, accepted?, resulting sidebar JSON when accepted) — base + // fixture: substrings ["keep"], mustStart false, autoGenerateTitles true. + let rows: &[(Value, bool, Option<&str>)] = &[ + ( + json!({"sidebar":{"excludeFirstChatMustStart":"yes"}}), + true, + Some( + r#"{"autoGenerateTitles":true,"excludeFirstChatMustStart":true,"excludeFirstChatSubstrings":["keep"]}"#, + ), + ), + ( + json!({"sidebar":{"excludeFirstChatMustStart":""}}), + true, + Some( + r#"{"autoGenerateTitles":true,"excludeFirstChatMustStart":false,"excludeFirstChatSubstrings":["keep"]}"#, + ), + ), + ( + json!({"sidebar":{"excludeFirstChatMustStart":"false"}}), + true, + Some( + r#"{"autoGenerateTitles":true,"excludeFirstChatMustStart":true,"excludeFirstChatSubstrings":["keep"]}"#, + ), + ), + ( + json!({"sidebar":{"excludeFirstChatMustStart":0}}), + true, + Some( + r#"{"autoGenerateTitles":true,"excludeFirstChatMustStart":false,"excludeFirstChatSubstrings":["keep"]}"#, + ), + ), + ( + json!({"sidebar":{"excludeFirstChatMustStart":1}}), + true, + Some( + r#"{"autoGenerateTitles":true,"excludeFirstChatMustStart":true,"excludeFirstChatSubstrings":["keep"]}"#, + ), + ), + ( + json!({"sidebar":{"excludeFirstChatMustStart":null}}), + true, + Some( + r#"{"autoGenerateTitles":true,"excludeFirstChatMustStart":false,"excludeFirstChatSubstrings":["keep"]}"#, + ), + ), + ( + json!({"sidebar":{"excludeFirstChatMustStart":{}}}), + true, + Some( + r#"{"autoGenerateTitles":true,"excludeFirstChatMustStart":true,"excludeFirstChatSubstrings":["keep"]}"#, + ), + ), + ( + json!({"sidebar":{"excludeFirstChatMustStart":[]}}), + true, + Some( + r#"{"autoGenerateTitles":true,"excludeFirstChatMustStart":true,"excludeFirstChatSubstrings":["keep"]}"#, + ), + ), + ( + json!({"sidebar":{"excludeFirstChatSubstrings":[" a ","a",""," b ","b"]}}), + true, + Some( + r#"{"autoGenerateTitles":true,"excludeFirstChatMustStart":false,"excludeFirstChatSubstrings":["a","b"]}"#, + ), + ), + ( + json!({"sidebar":{"excludeFirstChatSubstrings":"nope"}}), + false, + None, + ), + ( + json!({"sidebar":{"excludeFirstChatSubstrings":[1,"a"]}}), + false, + None, + ), + ( + json!({"sidebar":{"excludeFirstChatSubstrings":["x"],"excludeFirstChatMustStart":true,"bogus":1}}), + false, + None, + ), + (json!({"sidebar":{"bogus":1}}), false, None), + (json!({"sidebar":"nope"}), false, None), + ( + json!({"sidebar":{"ignoreCodexSubagentSessions":true}}), + true, + Some( + r#"{"autoGenerateTitles":true,"excludeFirstChatMustStart":false,"excludeFirstChatSubstrings":["keep"]}"#, + ), + ), + ( + json!({"sidebar":{}}), + true, + Some( + r#"{"autoGenerateTitles":true,"excludeFirstChatMustStart":false,"excludeFirstChatSubstrings":["keep"]}"#, + ), + ), + ( + json!({"sidebar":{"autoGenerateTitles":"yes"}}), + true, + Some( + r#"{"autoGenerateTitles":true,"excludeFirstChatMustStart":false,"excludeFirstChatSubstrings":["keep"]}"#, + ), + ), + ( + json!({"sidebar":{"autoGenerateTitles":0}}), + true, + Some( + r#"{"autoGenerateTitles":false,"excludeFirstChatMustStart":false,"excludeFirstChatSubstrings":["keep"]}"#, + ), + ), + ( + json!({"sidebar":{"excludeFirstChatSubstrings":[]}}), + true, + Some( + r#"{"autoGenerateTitles":true,"excludeFirstChatMustStart":false,"excludeFirstChatSubstrings":[]}"#, + ), + ), + ]; + for (i, (patch, accepted, sidebar_json)) in rows.iter().enumerate() { + let dir = std::env::temp_dir().join(format!("frs-s13-oracle-{}-{}", i, uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + let store = store_at(&dir); + // Base fixture: substrings ["keep"] (the oracle battery's base). + store + .patch(&json!({ "sidebar": { "excludeFirstChatSubstrings": ["keep"] } })) + .await + .unwrap(); + match store.patch(patch).await { + Ok(merged) => { + assert!(accepted, "row {i}: legacy REJECTS this patch"); + let sidebar = serde_json::to_string(&merged.sidebar).unwrap(); + assert_eq!(&sidebar, sidebar_json.unwrap(), "row {i} byte parity"); + } + Err((_status, _body)) => { + assert!(!accepted, "row {i}: legacy ACCEPTS this patch"); + } + } + std::fs::remove_dir_all(&dir).ok(); + } + } + + /// Same document-preservation guarantee through the terminal-override + /// persist path (`patch_terminal_override`). + #[tokio::test] + async fn terminal_override_patch_preserves_unmanaged_top_level_document_state() { + let dir = std::env::temp_dir().join(format!("frs-lossless-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + std::fs::write( + dir.join(".freshell").join("config.json"), + lossless_fixture_text(), + ) + .unwrap(); + let store = store_at(&dir); + + store + .patch_terminal_override("term-1", &[("deleted", Some(json!(true)))]) + .await; + + let cfg: Value = serde_json::from_str( + &std::fs::read_to_string(dir.join(".freshell").join("config.json")).unwrap(), + ) + .unwrap(); + assert_unmanaged_document_state_preserved(&cfg); + assert_eq!(cfg["terminalOverrides"]["term-1"]["deleted"], json!(true)); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// Same document-preservation guarantee through the session-override + /// persist path (`patch_session_override`). + #[tokio::test] + async fn session_override_patch_preserves_unmanaged_top_level_document_state() { + let dir = std::env::temp_dir().join(format!("frs-lossless-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + std::fs::write( + dir.join(".freshell").join("config.json"), + lossless_fixture_text(), + ) + .unwrap(); + let store = store_at(&dir); + + store + .patch_session_override("claude:abc", &[("archived", Some(json!(true)))]) + .await; + + let cfg: Value = serde_json::from_str( + &std::fs::read_to_string(dir.join(".freshell").join("config.json")).unwrap(), + ) + .unwrap(); + assert_unmanaged_document_state_preserved(&cfg); + assert_eq!( + cfg["sessionOverrides"]["claude:abc"]["archived"], + json!(true) + ); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// Same document-preservation guarantee through the project-color persist + /// path (`set_project_color` — `PUT /api/project-colors`). The + /// project-color family below uses reduced fixtures; THIS test pins the + /// full CFG-01 sentinel set through the color writer specifically. + #[tokio::test] + async fn project_color_write_preserves_unmanaged_top_level_document_state() { + let dir = std::env::temp_dir().join(format!("frs-lossless-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + std::fs::write( + dir.join(".freshell").join("config.json"), + lossless_fixture_text(), + ) + .unwrap(); + let store = store_at(&dir); + + store.set_project_color("/proj/x", "#c0ffee").await.unwrap(); + + let cfg: Value = serde_json::from_str( + &std::fs::read_to_string(dir.join(".freshell").join("config.json")).unwrap(), + ) + .unwrap(); + assert_unmanaged_document_state_preserved(&cfg); + assert_eq!(cfg["projectColors"]["/proj/x"], json!("#c0ffee")); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// Same document-preservation guarantee through the BOOT-TIME + /// normalization persist (`SettingsStore::load`'s provider-seed branch): + /// a config missing `codingCli.knownProviders` is seeded + persisted at + /// boot (the original always `patchSettings` here, + /// `server/index.ts:276-299`) -- and that write must not drop a single + /// sentinel. This fixture deliberately OMITS `knownProviders` (unlike + /// `lossless_fixture_text`, where the whole point is that NO boot write + /// fires). + #[tokio::test] + async fn boot_provider_seed_persist_preserves_unmanaged_top_level_document_state() { + let dir = std::env::temp_dir().join(format!("frs-lossless-boot-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + std::fs::write( + dir.join(".freshell").join("config.json"), + r##"{ + "version": 1, + "settings": { + "codingCli": { + "enabledProviders": ["claude", "codex"], + "providers": {}, + "mcpServer": true + } + }, + "completedMigrations": ["ai-title-shadow-cleanup"], + "recentDirectories": ["/a", "/b", "/c"], + "serverSecrets": { + "codexDisplayIdSecret": "seed-secret-value", + "futureSiblingSecret": "sibling-sentinel-value" + }, + "legacyLocalSettingsSeed": { + "theme": "light", + "uiScale": 1.25, + "terminal": { "fontSize": 18, "fontFamily": "CFG01 Sentinel Mono" }, + "sidebar": { "sortMode": "project", "width": 280, "collapsed": true }, + "notifications": { "soundEnabled": false } + }, + "zzFutureKey": { "a": 1 }, + "sessionOverrides": { "claude:keep": { "archived": true } }, + "terminalOverrides": { "term-keep": { "titleOverride": "KeepMe" } }, + "projectColors": { "/proj/keep": "#123123" } + }"##, + ) + .unwrap(); + + // Boot IS the write under test: knownProviders is seeded + persisted. + let _store = store_at(&dir); + + let cfg: Value = serde_json::from_str( + &std::fs::read_to_string(dir.join(".freshell").join("config.json")).unwrap(), + ) + .unwrap(); + assert_unmanaged_document_state_preserved(&cfg); + assert_eq!( + cfg["settings"]["codingCli"]["knownProviders"], + json!(["claude", "codex"]), + "the boot provider seed must land" + ); + assert_eq!( + cfg["sessionOverrides"]["claude:keep"]["archived"], + json!(true), + "pre-existing override entries must survive the boot persist" + ); + assert_eq!( + cfg["terminalOverrides"]["term-keep"]["titleOverride"], + json!("KeepMe") + ); + assert_eq!(cfg["projectColors"]["/proj/keep"], json!("#123123")); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// Same guarantee through the OTHER boot-time normalization trigger: + /// stray browser-local keys inside `settings` are stripped into + /// `legacyLocalSettingsSeed` and persisted at boot (CFG-04, + /// `load_internal`'s `seed_normalization_persist`). Unrelated document + /// state must survive that write too. + #[tokio::test] + async fn boot_seed_strip_persist_preserves_unmanaged_top_level_document_state() { + let dir = std::env::temp_dir().join(format!("frs-lossless-boot-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + std::fs::write( + dir.join(".freshell").join("config.json"), + r##"{ + "version": 1, + "settings": { + "theme": "dark", + "uiScale": 1.5, + "codingCli": { + "enabledProviders": ["claude", "codex"], + "knownProviders": ["claude", "codex"], + "providers": {}, + "mcpServer": true + } + }, + "completedMigrations": ["ai-title-shadow-cleanup"], + "recentDirectories": ["/a", "/b", "/c"], + "serverSecrets": { + "codexDisplayIdSecret": "seed-secret-value", + "futureSiblingSecret": "sibling-sentinel-value" + }, + "zzFutureKey": { "a": 1 }, + "sessionOverrides": { "claude:keep": { "archived": true } }, + "terminalOverrides": { "term-keep": { "titleOverride": "KeepMe" } }, + "projectColors": { "/proj/keep": "#123123" } + }"##, + ) + .unwrap(); + + let store = store_at(&dir); + + let cfg: Value = serde_json::from_str( + &std::fs::read_to_string(dir.join(".freshell").join("config.json")).unwrap(), + ) + .unwrap(); + // Unmanaged state (minus the seed, which this boot legitimately + // WRITES — that is the writer's intended path). + assert_eq!( + cfg["completedMigrations"], + json!(["ai-title-shadow-cleanup"]) + ); + assert_eq!(cfg["recentDirectories"], json!(["/a", "/b", "/c"])); + assert_eq!( + cfg["serverSecrets"]["codexDisplayIdSecret"], + json!("seed-secret-value") + ); + assert_eq!( + cfg["serverSecrets"]["futureSiblingSecret"], + json!("sibling-sentinel-value") + ); + assert_eq!(cfg["zzFutureKey"], json!({ "a": 1 })); + assert_eq!( + cfg["sessionOverrides"]["claude:keep"]["archived"], + json!(true) + ); + assert_eq!( + cfg["terminalOverrides"]["term-keep"]["titleOverride"], + json!("KeepMe") + ); + assert_eq!(cfg["projectColors"]["/proj/keep"], json!("#123123")); + // The strip itself: stray local keys left `settings`, landed in the seed. + let seed = store + .legacy_local_settings_seed() + .expect("stray local keys must extract into the seed"); + assert_eq!(seed["theme"], json!("dark")); + assert_eq!(seed["uiScale"], json!(1.5)); + assert!( + cfg["settings"].get("theme").is_none(), + "the boot persist must strip the stray local key out of settings" + ); + assert_eq!(cfg["legacyLocalSettingsSeed"]["theme"], json!("dark")); + + std::fs::remove_dir_all(&dir).ok(); + } + + // ── Batch B: side-by-side operation with the legacy Node server ── + // + // These tests exercise a bake-in scenario where BOTH the Rust server + // and the legacy Node server make automatic writes to the SAME real + // `~/.freshell/config.json` (auto-titling sessionOverrides, provider + // seeding). An "external writer" below stands in for the legacy + // server: a direct `std::fs::write` to `config.json`, bypassing every + // Rust API, exactly as a concurrent process would. + + /// EXTERNAL-WRITER SURVIVAL: between Rust's boot-time load and a LATER + /// Rust persist (triggered by a patch to a DIFFERENT key), an external + /// writer rewrites `config.json` directly: adds a brand-new + /// `sessionOverrides` key, changes an EXISTING key Rust has never + /// touched this boot, and adds an unrelated unknown top-level key. All + /// three must survive the Rust persist, alongside Rust's own patch. + /// Pre-hardening `persist()` overlaid `session_overrides.lock().clone()` + /// onto the doc WHOLESALE, so the external addition and the external + /// edit to the untouched key would both have been silently erased -- /// this is the RED case for the whole feature. #[tokio::test] async fn external_writer_edits_survive_a_rust_persist_of_a_different_key() { @@ -3728,4 +5074,444 @@ mod tests { std::fs::set_permissions(&freshell, original_perms).unwrap(); std::fs::remove_dir_all(&dir).ok(); } + + // ------------------------------------------------------------------ + // SESSION-05 (project colors): `config.projectColors` + // (`config-store.ts:549-562`) — the legacy config-store exposes + // `setProjectColor`/`getProjectColors` over a top-level + // `Record` map; the Rust store must hold the same map + // in memory with the SAME side-by-side adopt-from-disk discipline as + // the override maps (dirty keys win; untouched keys defer to disk). + // ------------------------------------------------------------------ + + /// PERSIST-FAILURE ROLLBACK (legacy parity): a failed + /// `set_project_color` must leave the in-memory map AND the dirty set + /// exactly as they were before the attempt — mirroring + /// `ConfigStore.saveInternal`, which assigns `this.cache = cfg` only + /// AFTER the atomic write succeeds (`config-store.ts:424-435`). Covers + /// both shapes: overwriting an existing key (prior value restored) and + /// inserting a brand-new key (key absent afterwards), plus the + /// dirty-set consequence: a later external disk edit to the failed key + /// must still be adopted by the freshness reload (a stale dirty mark + /// would tombstone it in [`overlay_dirty_keys`]). + #[cfg(unix)] + #[tokio::test] + async fn set_project_color_rolls_back_in_memory_state_when_persist_fails() { + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!("frs-project-colors-{}", uuid_like())); + let freshell = dir.join(".freshell"); + std::fs::create_dir_all(&freshell).unwrap(); + std::fs::write( + freshell.join("config.json"), + serde_json::to_string(&json!({ + "version": 1, + "settings": {}, + "projectColors": { "/proj/alpha": "#aaaaaa" } + })) + .unwrap(), + ) + .unwrap(); + // Zero-width throttle window: every read re-stats (test-scaled). + let store = store_at(&dir).with_reload_throttle_window(std::time::Duration::ZERO); + + // `.freshell` read+execute only: persist()'s tmp-file create fails. + let original_perms = std::fs::metadata(&freshell).unwrap().permissions(); + std::fs::set_permissions(&freshell, std::fs::Permissions::from_mode(0o500)).unwrap(); + + // Overwrite an EXISTING key: must Err, and the prior value must be + // restored — never the failed write. + store + .set_project_color("/proj/alpha", "#bbbbbb") + .await + .expect_err("a read-only config dir must fail the write"); + let colors = store.project_colors(); + assert_eq!( + colors.get("/proj/alpha").and_then(Value::as_str), + Some("#aaaaaa"), + "a failed overwrite must restore the last-persisted value" + ); + + // Insert a NEW key: must Err, and the key must not be visible. + store + .set_project_color("/proj/beta", "#00ff00") + .await + .expect_err("a read-only config dir must fail the write"); + let colors = store.project_colors(); + assert!( + !colors.contains_key("/proj/beta"), + "a failed insert must not leak into project_colors(): {colors:?}" + ); + + // Dirty-set rollback: a later EXTERNAL disk edit to the failed key + // must be adopted by the freshness reload (20ms so the write lands + // on a later mtime tick — same pattern as + // `project_colors_external_write_becomes_visible_without_restart`). + std::thread::sleep(std::time::Duration::from_millis(20)); + let mut cfg: Value = + serde_json::from_str(&std::fs::read_to_string(freshell.join("config.json")).unwrap()) + .unwrap(); + cfg["projectColors"]["/proj/beta"] = json!("#123456"); + std::fs::write( + freshell.join("config.json"), + serde_json::to_string(&cfg).unwrap(), + ) + .unwrap(); + let colors = store.project_colors(); + assert_eq!( + colors.get("/proj/beta").and_then(Value::as_str), + Some("#123456"), + "a stale dirty mark must not tombstone the external edit" + ); + + // Restore permissions; a retry now succeeds and persists. + std::fs::set_permissions(&freshell, original_perms).unwrap(); + store + .set_project_color("/proj/alpha", "#bbbbbb") + .await + .expect("a writable config dir must persist"); + let cfg: Value = + serde_json::from_str(&std::fs::read_to_string(freshell.join("config.json")).unwrap()) + .unwrap(); + assert_eq!(cfg["projectColors"]["/proj/alpha"], json!("#bbbbbb")); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// LOAD + ROUND-TRIP: a boot-time `projectColors` map is readable via + /// `project_colors()`; `set_project_color` persists so the color is + /// visible to a FRESH `SettingsStore::load` without clobbering either + /// the boot-seeded color, an unrelated unknown top-level key, or the + /// seeded empty defaults (`sessionOverrides`/`terminalOverrides`). + #[tokio::test] + async fn project_colors_round_trip_preserves_existing_entries_and_unrelated_keys() { + let dir = std::env::temp_dir().join(format!("frs-project-colors-{}", uuid_like())); + let freshell = dir.join(".freshell"); + std::fs::create_dir_all(&freshell).unwrap(); + std::fs::write( + freshell.join("config.json"), + serde_json::to_string(&json!({ + "version": 1, + "settings": {}, + "sessionOverrides": { "claude:s1": { "titleOverride": "KeepMe" } }, + "projectColors": { "/proj/alpha": "#ff0000" }, + "customPluginState": { "anything": true } + })) + .unwrap(), + ) + .unwrap(); + + let store = store_at(&dir); + let colors = store.project_colors(); + assert_eq!( + colors.get("/proj/alpha").and_then(Value::as_str), + Some("#ff0000"), + "a boot-seeded project color must be visible without any write" + ); + + store + .set_project_color("/proj/beta", "#00ff00") + .await + .expect("set_project_color must succeed on a writable config dir"); + + // The in-memory reader reflects the write immediately. + let colors = store.project_colors(); + assert_eq!( + colors.get("/proj/beta").and_then(Value::as_str), + Some("#00ff00") + ); + assert_eq!( + colors.get("/proj/alpha").and_then(Value::as_str), + Some("#ff0000") + ); + + // On disk: both colors plus every unrelated key. + let cfg: Value = + serde_json::from_str(&std::fs::read_to_string(freshell.join("config.json")).unwrap()) + .unwrap(); + assert_eq!(cfg["projectColors"]["/proj/alpha"], json!("#ff0000")); + assert_eq!(cfg["projectColors"]["/proj/beta"], json!("#00ff00")); + assert_eq!( + cfg["sessionOverrides"]["claude:s1"]["titleOverride"], + json!("KeepMe"), + "unrelated session overrides must survive a color write" + ); + assert_eq!( + cfg["customPluginState"]["anything"], + json!(true), + "unknown top-level keys must round-trip through a color write" + ); + + // A fresh process (another load) sees both colors. + let reloaded = store_at(&dir); + let colors = reloaded.project_colors(); + assert_eq!( + colors.get("/proj/alpha").and_then(Value::as_str), + Some("#ff0000") + ); + assert_eq!( + colors.get("/proj/beta").and_then(Value::as_str), + Some("#00ff00") + ); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// OVERWRITE + ADDITIVE: setting a second color must never clobber the + /// first, and re-setting the same path replaces its value. + #[tokio::test] + async fn project_colors_set_is_additive_and_overwrites_same_path() { + let dir = std::env::temp_dir().join(format!("frs-project-colors-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + let store = store_at(&dir); + + store.set_project_color("/proj/a", "#111111").await.unwrap(); + store.set_project_color("/proj/b", "#222222").await.unwrap(); + store.set_project_color("/proj/a", "#333333").await.unwrap(); + + let colors = store.project_colors(); + assert_eq!( + colors.get("/proj/a").and_then(Value::as_str), + Some("#333333") + ); + assert_eq!( + colors.get("/proj/b").and_then(Value::as_str), + Some("#222222") + ); + assert_eq!(colors.len(), 2); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// FRESH-INSTALL SEED: with no config at all, the first persist still + /// writes the legacy first-write defaults (`projectColors: {}` — + /// `config-store.ts:356-360, 394`). + #[tokio::test] + async fn project_colors_seeds_empty_map_on_first_write_like_the_original() { + let dir = std::env::temp_dir().join(format!("frs-project-colors-{}", uuid_like())); + let freshell = dir.join(".freshell"); + std::fs::create_dir_all(&freshell).unwrap(); + let store = store_at(&dir); + + store + .set_project_color("/proj/only", "#abcdef") + .await + .unwrap(); + + let cfg: Value = + serde_json::from_str(&std::fs::read_to_string(freshell.join("config.json")).unwrap()) + .unwrap(); + assert!( + cfg["projectColors"].is_object(), + "projectColors must be an object" + ); + assert_eq!(cfg["projectColors"]["/proj/only"], json!("#abcdef")); + assert!( + cfg["sessionOverrides"].is_object(), + "the legacy first-write defaults still seed alongside" + ); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// EXTERNAL-WRITER SURVIVAL (project colors): an external writer's new + /// color key AND edit to a color key Rust never touched this boot both + /// survive a Rust persist for a DIFFERENT color (same discipline as + /// `external_writer_edits_survive_a_rust_persist_of_a_different_key`). + #[tokio::test] + async fn project_colors_external_writer_edits_survive_a_rust_persist_of_a_different_color() { + let dir = std::env::temp_dir().join(format!("frs-project-colors-{}", uuid_like())); + let freshell = dir.join(".freshell"); + std::fs::create_dir_all(&freshell).unwrap(); + std::fs::write( + freshell.join("config.json"), + serde_json::to_string(&json!({ + "version": 1, + "settings": {}, + "projectColors": { "/proj/orig": "#aaaaaa" } + })) + .unwrap(), + ) + .unwrap(); + let store = store_at(&dir); + + // External writer (the legacy Node server, or another Rust + // process): edits the EXT pre-existing key and adds a new one. + std::fs::write( + freshell.join("config.json"), + serde_json::to_string(&json!({ + "version": 1, + "settings": {}, + "projectColors": { + "/proj/orig": "#bbbbbb", + "/proj/external": "#cccccc" + } + })) + .unwrap(), + ) + .unwrap(); + + // Rust colors a DIFFERENT project -- triggers a persist. + store + .set_project_color("/proj/ours", "#dddddd") + .await + .unwrap(); + + let cfg: Value = + serde_json::from_str(&std::fs::read_to_string(freshell.join("config.json")).unwrap()) + .unwrap(); + assert_eq!( + cfg["projectColors"]["/proj/orig"], + json!("#bbbbbb"), + "external edit to a key Rust never touched this boot must survive" + ); + assert_eq!( + cfg["projectColors"]["/proj/external"], + json!("#cccccc"), + "a brand-new external color key must survive" + ); + assert_eq!(cfg["projectColors"]["/proj/ours"], json!("#dddddd")); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// DIRTY-KEY WINS (project colors): once THIS process has set a color, + /// a concurrent external edit to the SAME path must not survive a later + /// Rust persist (same rule as `session_overrides_dirty`). + #[tokio::test] + async fn project_colors_dirty_key_wins_over_a_concurrent_external_edit() { + let dir = std::env::temp_dir().join(format!("frs-project-colors-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + let freshell = dir.join(".freshell"); + let store = store_at(&dir); + + store + .set_project_color("/proj/hot", "#111111") + .await + .unwrap(); + + // External writer overwrites the SAME path. + let mut cfg: Value = + serde_json::from_str(&std::fs::read_to_string(freshell.join("config.json")).unwrap()) + .unwrap(); + cfg["projectColors"]["/proj/hot"] = json!("#999999"); + std::fs::write( + freshell.join("config.json"), + serde_json::to_string(&cfg).unwrap(), + ) + .unwrap(); + + // A persist for ANY other reason (here: another color write). + store + .set_project_color("/proj/cold", "#222222") + .await + .unwrap(); + + let cfg: Value = + serde_json::from_str(&std::fs::read_to_string(freshell.join("config.json")).unwrap()) + .unwrap(); + assert_eq!( + cfg["projectColors"]["/proj/hot"], + json!("#111111"), + "a key this process touched must reflect Rust's last write" + ); + assert_eq!(cfg["projectColors"]["/proj/cold"], json!("#222222")); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// JUNK TOLERANCE: a hand-edited `projectColors` entry with a + /// non-string VALUE is dropped from the reader (the wire schema is + /// `z.record(z.string(), z.string())` and the client `typeof`-guards — + /// a junk value must never flow to the page or it would fail client + /// parse of the whole fetch). The disk file itself is left alone. + #[tokio::test] + async fn project_colors_drops_non_string_values_but_keeps_disk_asis() { + let dir = std::env::temp_dir().join(format!("frs-project-colors-{}", uuid_like())); + let freshell = dir.join(".freshell"); + std::fs::create_dir_all(&freshell).unwrap(); + std::fs::write( + freshell.join("config.json"), + serde_json::to_string(&json!({ + "version": 1, + "settings": {}, + "projectColors": { + "/proj/good": "#ff0000", + "/proj/junk": 42 + } + })) + .unwrap(), + ) + .unwrap(); + let store = store_at(&dir); + + let colors = store.project_colors(); + assert_eq!( + colors.get("/proj/good").and_then(Value::as_str), + Some("#ff0000") + ); + assert!( + !colors.contains_key("/proj/junk"), + "a non-string color value must be normalized away, got: {colors:?}" + ); + + // The write path for a SIBLING key must not resurrect the junk + // into memory... and the persisted file keeps the original junk + // value for the untouched key only if it was never written + // (adopt-from-disk passes the disk map through). + store + .set_project_color("/proj/other", "#00ff00") + .await + .unwrap(); + let colors = store.project_colors(); + assert!(!colors.contains_key("/proj/junk")); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// FRESHNESS RELOAD reads project colors too: an external write becomes + /// visible via `project_colors()` without a restart (the mtime-checked + /// reload applied to the override maps must cover colors, so a bake-in + /// partner's color write shows up on the next directory read). + #[tokio::test] + async fn project_colors_external_write_becomes_visible_without_restart() { + let dir = std::env::temp_dir().join(format!("frs-project-colors-{}", uuid_like())); + let freshell = dir.join(".freshell"); + std::fs::create_dir_all(&freshell).unwrap(); + std::fs::write( + freshell.join("config.json"), + serde_json::to_string(&json!({ + "version": 1, + "settings": {}, + "projectColors": {} + })) + .unwrap(), + ) + .unwrap(); + // Zero-width throttle window: every read re-stats (test-scaled). + let store = store_at(&dir).with_reload_throttle_window(std::time::Duration::ZERO); + assert!(store.project_colors().is_empty()); + + // Ensure the external write lands on a LATER mtime tick than the + // boot load's initial `last_known_mtime` stamp. + std::thread::sleep(std::time::Duration::from_millis(20)); + let mut cfg: Value = + serde_json::from_str(&std::fs::read_to_string(freshell.join("config.json")).unwrap()) + .unwrap(); + cfg["projectColors"]["/proj/later"] = json!("#fedcba"); + std::fs::write( + freshell.join("config.json"), + serde_json::to_string(&cfg).unwrap(), + ) + .unwrap(); + + let colors = store.project_colors(); + assert_eq!( + colors.get("/proj/later").and_then(Value::as_str), + Some("#fedcba"), + "an external color write must be adopted by the freshness reload" + ); + + std::fs::remove_dir_all(&dir).ok(); + } } diff --git a/crates/freshell-server/src/test_clock_gate.rs b/crates/freshell-server/src/test_clock_gate.rs new file mode 100644 index 000000000..9e5339858 --- /dev/null +++ b/crates/freshell-server/src/test_clock_gate.rs @@ -0,0 +1,41 @@ +//! HARNESS-14 test-only helper: serialize + scope the process-global +//! shared test-clock override (`freshell_platform::clock`) for this +//! crate's test binary. +//! +//! `TestClockGate::enable(state)` installs the override (on or forced-off), +//! resets the clock, and holds a crate-wide lock so parallel tests cannot +//! interleave gate flips. Drop resets the clock and clears the override. +//! Poison-tolerant: a panicking sibling cannot cascade the clock suites. + +use std::sync::{Mutex, MutexGuard}; + +use freshell_platform::clock; + +static LOCK: Mutex<()> = Mutex::new(()); + +pub struct TestClockGate { + _guard: MutexGuard<'static, ()>, +} + +impl TestClockGate { + pub fn enable() -> Self { + Self::locked(true) + } + + pub fn locked(enabled_state: bool) -> Self { + let guard = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + clock::set_enabled_override_for_tests(Some(enabled_state)); + if enabled_state { + clock::reset().expect("override just enabled"); + } + Self { _guard: guard } + } +} + +impl Drop for TestClockGate { + fn drop(&mut self) { + clock::set_enabled_override_for_tests(Some(true)); + let _ = clock::reset(); + clock::set_enabled_override_for_tests(None); + } +} diff --git a/crates/freshell-server/src/test_clock_router.rs b/crates/freshell-server/src/test_clock_router.rs new file mode 100644 index 000000000..25dcd6bf0 --- /dev/null +++ b/crates/freshell-server/src/test_clock_router.rs @@ -0,0 +1,336 @@ +//! HARNESS-14 — the Rust server's test-clock control surface. +//! +//! Five endpoints driving the shared [`freshell_platform::clock`] test +//! clock, mounted by `main.rs` ONLY when `FRESHELL_TEST_CLOCK` enabled the +//! clock at boot — and even then every handler re-checks +//! [`clock::enabled()`], so a future placement mistake can never expose the +//! surface in a normal build (defense in depth; the disabled answer is the +//! same indistinguishable 404 the SPA fallback gives an unmounted `/api/*` +//! route, `main.rs`'s "clean 404" comment). +//! +//! Parity: the legacy server mounts the identical surface from +//! `server/test-clock-router.ts` — same paths, same JSON envelopes, same +//! auth gate (`x-auth-token` header / `freshell-auth` cookie, constant-time +//! compare via [`is_authed`]). A spec can therefore drive either server +//! implementation with one code path. +//! +//! ```text +//! GET /api/test-clock → 200 { ok:true, enabled:true, mode:'live'|'frozen', nowMs, offsetMs } +//! POST /api/test-clock/advance {ms} → 200 same shape | 400 { ok:false, error:'invalid_advance', message } +//! POST /api/test-clock/freeze → 200 same shape +//! POST /api/test-clock/resume → 200 same shape +//! POST /api/test-clock/reset → 200 same shape +//! (any of the above, no/invalid token) → 401 { "error": "Unauthorized" } +//! (gate off) → 404 { "error": "Not found" } +//! ``` + +use std::sync::Arc; + +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use serde_json::{json, Value}; + +use freshell_platform::clock::{self, ClockSnapshot}; + +use crate::boot::{is_authed, unauthorized}; + +/// Shared state for the test-clock router: just the auth token (the clock +/// itself is the process-global `freshell_platform::clock`). +#[derive(Clone)] +pub struct TestClockState { + pub auth_token: Arc, +} + +pub fn router(state: TestClockState) -> Router { + Router::new() + .route("/api/test-clock", get(get_clock)) + .route("/api/test-clock/advance", post(post_advance)) + .route("/api/test-clock/freeze", post(post_freeze)) + .route("/api/test-clock/resume", post(post_resume)) + .route("/api/test-clock/reset", post(post_reset)) + .with_state(state) +} + +/// The REST field projection of a [`ClockSnapshot`] (camelCase, mirroring +/// the legacy JSON envelope exactly). +fn snapshot_json(snap: ClockSnapshot) -> Value { + json!({ + "ok": true, + "enabled": snap.enabled, + "mode": snap.mode.as_str(), + "nowMs": snap.now_ms, + "offsetMs": snap.offset_ms, + }) +} + +/// The disabled-gate reject: byte-identical to the legacy catch-all / +/// SPA-fallback "no such route" body, so an off-gate deployment is +/// indistinguishable from one where the surface was never compiled in. +fn not_found() -> Response { + (StatusCode::NOT_FOUND, Json(json!({ "error": "Not found" }))).into_response() +} + +/// Uniform pre-handler gate: auth first (401 mirrors every other `/api/*` +/// route), then the enabled check (404 when the clock is off). +fn gate(headers: &HeaderMap, state: &TestClockState) -> Option { + if !is_authed(headers, &state.auth_token) { + return Some(unauthorized()); + } + if !clock::enabled() { + return Some(not_found()); + } + None +} + +fn invalid_advance(message: &str) -> Response { + ( + StatusCode::BAD_REQUEST, + Json(json!({ + "ok": false, + "error": "invalid_advance", + "message": message, + })), + ) + .into_response() +} + +async fn get_clock(State(state): State, headers: HeaderMap) -> Response { + if let Some(reject) = gate(&headers, &state) { + return reject; + } + Json(snapshot_json(clock::snapshot())).into_response() +} + +async fn post_advance( + State(state): State, + headers: HeaderMap, + body: Option>, +) -> Response { + if let Some(reject) = gate(&headers, &state) { + return reject; + } + // `req.body || {}` parity with the legacy router: a missing body is + // validated as `{}`, which then fails the ms check with a useful 400. + let ms = body + .and_then(|Json(v)| v.get("ms").and_then(Value::as_i64)) + // as_i64 rejects floats (parity: legacy requires Number.isInteger), + // strings, and missing keys uniformly. + .filter(|ms| (0..=clock::MAX_ADVANCE_MS).contains(ms)); + let Some(ms) = ms else { + return invalid_advance("body.ms must be an integer in [0, MAX_ADVANCE_MS] (31 days)"); + }; + match clock::advance_ms(ms) { + Ok(snap) => Json(snapshot_json(snap)).into_response(), + // Unreachable while gated (the gate checked enabled first), but + // never panic on a control surface. + Err(_) => not_found(), + } +} + +async fn post_freeze(State(state): State, headers: HeaderMap) -> Response { + if let Some(reject) = gate(&headers, &state) { + return reject; + } + match clock::freeze() { + Ok(snap) => Json(snapshot_json(snap)).into_response(), + Err(_) => not_found(), + } +} + +async fn post_resume(State(state): State, headers: HeaderMap) -> Response { + if let Some(reject) = gate(&headers, &state) { + return reject; + } + match clock::resume() { + Ok(snap) => Json(snapshot_json(snap)).into_response(), + Err(_) => not_found(), + } +} + +async fn post_reset(State(state): State, headers: HeaderMap) -> Response { + if let Some(reject) = gate(&headers, &state) { + return reject; + } + match clock::reset() { + Ok(snap) => Json(snapshot_json(snap)).into_response(), + Err(_) => not_found(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + + // Serialize + scope the process-global clock override (HARNESS-14). + use crate::test_clock_gate::TestClockGate as OverrideGuard; + + fn app() -> Router { + router(TestClockState { + auth_token: Arc::new("tok".to_string()), + }) + } + + async fn call( + method: &str, + uri: &str, + token: Option<&str>, + body: Option, + ) -> (StatusCode, Value) { + let mut req = Request::builder().method(method).uri(uri); + // Only a present body carries a JSON content-type: axum's + // `Option>` tolerates a MISSING content-type but rejects + // a json-typed EMPTY body before the handler ever runs (400 + // plain-text), which would preempt this router's own 400 envelope. + if body.is_some() { + req = req.header("content-type", "application/json"); + } + if let Some(token) = token { + req = req.header("x-auth-token", token); + } + let resp = app() + .oneshot( + req.body(match body { + Some(v) => Body::from(v.to_string()), + None => Body::empty(), + }) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).unwrap_or_else(|e| { + panic!( + "unparseable response body for {method} {uri}: {e}; raw={:?}", + String::from_utf8_lossy(&bytes) + ) + }) + }; + (status, json) + } + + #[tokio::test] + async fn unauthenticated_requests_are_401_before_any_gate_logic() { + // No override needed: auth precedes the enabled check, so every + // verb rejects first — even in a production (gate-off) process. + for (method, uri) in [ + ("GET", "/api/test-clock"), + ("POST", "/api/test-clock/advance"), + ("POST", "/api/test-clock/freeze"), + ("POST", "/api/test-clock/resume"), + ("POST", "/api/test-clock/reset"), + ] { + let (status, body) = call(method, uri, None, None).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{method} {uri}"); + assert_eq!(body, json!({ "error": "Unauthorized" })); + } + } + + #[tokio::test] + async fn gate_off_every_verb_is_an_indistinguishable_404() { + let _guard = OverrideGuard::locked(false); + for (method, uri) in [ + ("GET", "/api/test-clock"), + ("POST", "/api/test-clock/advance"), + ("POST", "/api/test-clock/freeze"), + ("POST", "/api/test-clock/resume"), + ("POST", "/api/test-clock/reset"), + ] { + let (status, body) = call(method, uri, Some("tok"), None).await; + assert_eq!(status, StatusCode::NOT_FOUND, "{method} {uri}"); + assert_eq!(body, json!({ "error": "Not found" })); + } + } + + #[tokio::test] + async fn get_reports_enabled_live_state() { + let _guard = OverrideGuard::locked(true); + let (status, body) = call("GET", "/api/test-clock", Some("tok"), None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["ok"], json!(true)); + assert_eq!(body["enabled"], json!(true)); + assert_eq!(body["mode"], json!("live")); + assert_eq!(body["offsetMs"], json!(0)); + let now_ms = body["nowMs"].as_i64().expect("nowMs integer"); + let real = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + assert!((now_ms - real).abs() < 5_000); + } + + #[tokio::test] + async fn advance_freeze_resume_reset_round_trip_over_http() { + let _guard = OverrideGuard::locked(true); + + let (s, b) = call( + "POST", + "/api/test-clock/advance", + Some("tok"), + Some(json!({ "ms": 90_000 })), + ) + .await; + assert_eq!((s, b["offsetMs"].as_i64()), (StatusCode::OK, Some(90_000))); + + let (s, b) = call("POST", "/api/test-clock/freeze", Some("tok"), None).await; + assert_eq!((s, b["mode"].as_str()), (StatusCode::OK, Some("frozen"))); + let held = b["nowMs"].as_i64().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(20)); + let (_, b2) = call("GET", "/api/test-clock", Some("tok"), None).await; + assert_eq!( + b2["nowMs"].as_i64(), + Some(held), + "frozen time must not move" + ); + + let (s, b) = call("POST", "/api/test-clock/resume", Some("tok"), None).await; + assert_eq!((s, b["mode"].as_str()), (StatusCode::OK, Some("live"))); + assert!( + (b["nowMs"].as_i64().unwrap() - held).abs() < 1_000, + "no jump on resume" + ); + + let (s, b) = call("POST", "/api/test-clock/reset", Some("tok"), None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(b["offsetMs"], json!(0)); + assert_eq!(b["mode"], json!("live")); + } + + #[tokio::test] + async fn advance_rejects_invalid_bodies_with_400_and_no_mutation() { + let _guard = OverrideGuard::locked(true); + for body in [ + json!({ "ms": -1 }), + json!({ "ms": 1.5 }), + json!({ "ms": "60000" }), + json!({ "ms": clock::MAX_ADVANCE_MS + 1 }), + json!({}), + json!("hello"), + ] { + let (status, body) = + call("POST", "/api/test-clock/advance", Some("tok"), Some(body)).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert_eq!(body["error"], json!("invalid_advance")); + assert!(body["message"].is_string()); + } + // No body at all: also a 400 (never a handler panic). + let (status, _) = call("POST", "/api/test-clock/advance", Some("tok"), None).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + // Nothing mutated. + let (_, b) = call("GET", "/api/test-clock", Some("tok"), None).await; + assert_eq!(b["offsetMs"], json!(0)); + } +} diff --git a/crates/freshell-server/tests/browser01_proxy.rs b/crates/freshell-server/tests/browser01_proxy.rs new file mode 100644 index 000000000..898b93b17 --- /dev/null +++ b/crates/freshell-server/tests/browser01_proxy.rs @@ -0,0 +1,534 @@ +//! BROWSER-01 outer black-box test: boots the REAL `freshell-server` binary +//! (diag01 pattern — this crate is `[[bin]]`-only, so the mounted-app wiring +//! is only provable by driving the compiled thing over real sockets) and +//! exercises the same-origin reverse proxy (`/api/proxy/http//*`) +//! end to end: mount + `is_authed` gate + raw path/query (G1), duplicate +//! headers (G2), content-length + exact removal set (G4), and the legacy +//! 400/401/502 shapes — with a raw TCP upstream capturing verbatim bytes and +//! a raw client asserting verbatim responses (no framework normalization on +//! either side of the wire). +//! +//! The fine-grained streaming/body contract lives in the in-module socket +//! tests (`src/proxy.rs::socket_contract*`); this file pins the main.rs +//! wiring proof and the headline behaviors. + +use std::io::Read; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +// ── Binary boot (mirrors diag01_diag03_logging.rs) ───────────────────────── + +fn discover_server_binary() -> PathBuf { + if let Some(explicit) = std::env::var_os("FRESHELL_SERVER_BIN") { + return PathBuf::from(explicit); + } + let suffix = std::env::consts::EXE_SUFFIX; + if let Some(found) = find_sibling(suffix) { + return found; + } + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let status = Command::new(env!("CARGO")) + .args(["build", "--bin", "freshell-server"]) + .current_dir(&manifest_dir) + .status() + .expect("spawn `cargo build --bin freshell-server`"); + assert!(status.success(), "cargo build --bin freshell-server failed"); + find_sibling(suffix).expect("freshell-server binary not found even after building it") +} + +fn find_sibling(suffix: &str) -> Option { + let exe = std::env::current_exe().ok()?; + for dir in exe.ancestors().skip(1).take(3) { + let candidate = dir.join(format!("freshell-server{suffix}")); + if candidate.exists() { + return Some(candidate); + } + } + None +} + +fn allocate_ephemeral_port() -> u16 { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port"); + listener.local_addr().expect("local_addr").port() +} + +async fn wait_for_health(port: u16, child: &mut Child, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + let url = format!("http://127.0.0.1:{port}/api/health"); + while Instant::now() < deadline { + if let Ok(Some(_)) = child.try_wait() { + return false; + } + if let Ok(resp) = reqwest::Client::new().get(&url).send().await { + if resp.status().is_success() { + return true; + } + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + false +} + +fn drain_stderr(child: &mut Child) -> String { + let mut buf = String::new(); + if let Some(stderr) = child.stderr.as_mut() { + let _ = stderr.read_to_string(&mut buf); + } + buf +} + +/// Kill-on-drop guard: a panicking assertion must never orphan the spawned +/// server (std's `Child` does NOT kill on drop; a leaked freshell-server +/// would hold its port and confuse sibling swarm workers). +struct ChildGuard(Child); + +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +// ── Raw wire helpers (verbatim both directions) ──────────────────────────── + +fn find_subslice(hay: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() || hay.len() < needle.len() { + return None; + } + hay.windows(needle.len()).position(|w| w == needle) +} + +fn parse_head(raw: &[u8]) -> (String, Vec<(String, String)>) { + let text = String::from_utf8_lossy(raw); + let mut lines = text.split("\r\n"); + let first = lines.next().unwrap_or("").to_string(); + let headers = lines + .take_while(|l| !l.is_empty()) + .filter_map(|l| { + l.split_once(':') + .map(|(k, v)| (k.trim().to_string(), v.trim().to_string())) + }) + .collect(); + (first, headers) +} + +fn header_values<'a>( + headers: &'a [(String, String)], + name: &'a str, +) -> impl Iterator { + headers + .iter() + .filter(move |(n, _)| n.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) +} + +struct Wire { + stream: tokio::net::TcpStream, + buf: Vec, +} + +impl Wire { + fn new(stream: tokio::net::TcpStream) -> Self { + Self { + stream, + buf: Vec::new(), + } + } + + async fn write_all(&mut self, bytes: &[u8]) { + self.stream.write_all(bytes).await.unwrap(); + } + + async fn read_until(&mut self, needle: &[u8]) -> Vec { + loop { + if let Some(pos) = find_subslice(&self.buf, needle) { + let end = pos + needle.len(); + return self.buf.drain(..end).collect(); + } + let mut tmp = [0u8; 8192]; + let n = self.stream.read(&mut tmp).await.unwrap(); + if n == 0 { + return std::mem::take(&mut self.buf); + } + self.buf.extend_from_slice(&tmp[..n]); + } + } + + async fn read_n(&mut self, n: usize) -> Vec { + while self.buf.len() < n { + let mut tmp = [0u8; 8192]; + let r = self.stream.read(&mut tmp).await.unwrap(); + if r == 0 { + break; + } + self.buf.extend_from_slice(&tmp[..r]); + } + let take = self.buf.len().min(n); + self.buf.drain(..take).collect() + } + + async fn read_chunked(&mut self) -> Vec { + let mut body = Vec::new(); + loop { + let size_line = self.read_until(b"\r\n").await; + let size_text = String::from_utf8_lossy(&size_line); + let size_text = size_text.trim(); + let size = usize::from_str_radix(size_text.split(';').next().unwrap_or("").trim(), 16) + .unwrap_or_else(|_| panic!("bad chunk size line {size_text:?}")); + if size == 0 { + loop { + let line = self.read_until(b"\r\n").await; + if line == b"\r\n" { + break; + } + } + break; + } + body.extend_from_slice(&self.read_n(size).await); + let crlf = self.read_n(2).await; + assert_eq!(crlf, b"\r\n", "chunk terminator"); + } + body + } +} + +#[derive(Debug, Default)] +struct CapturedRequest { + request_line: String, + headers: Vec<(String, String)>, + body: Vec, +} + +impl CapturedRequest { + fn raw_target(&self) -> &str { + self.request_line.split(' ').nth(1).unwrap_or("") + } +} + +async fn read_request(wire: &mut Wire) -> CapturedRequest { + let head = wire.read_until(b"\r\n\r\n").await; + let (request_line, headers) = parse_head(&head); + let body = if header_values(&headers, "transfer-encoding").any(|v| v.contains("chunked")) { + wire.read_chunked().await + } else if let Some(cl) = header_values(&headers, "content-length").next() { + wire.read_n(cl.parse().expect("content-length integer")) + .await + } else { + Vec::new() + }; + CapturedRequest { + request_line, + headers, + body, + } +} + +struct RawResponse { + status_line: String, + headers: Vec<(String, String)>, + body: Vec, +} + +impl RawResponse { + fn status_code(&self) -> u16 { + self.status_line + .split(' ') + .nth(1) + .and_then(|c| c.parse().ok()) + .expect("status code") + } + fn header_values<'a>(&'a self, name: &'a str) -> impl Iterator { + header_values(&self.headers, name) + } +} + +async fn raw_exchange(port: u16, request: &[u8]) -> RawResponse { + tokio::time::timeout(Duration::from_secs(15), async { + let stream = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .unwrap(); + let mut wire = Wire::new(stream); + wire.write_all(request).await; + let head = wire.read_until(b"\r\n\r\n").await; + let (status_line, headers) = parse_head(&head); + let body = if header_values(&headers, "transfer-encoding").any(|v| v.contains("chunked")) { + wire.read_chunked().await + } else if let Some(cl) = header_values(&headers, "content-length").next() { + wire.read_n(cl.parse().expect("content-length integer")) + .await + } else { + wire.read_to_eof().await + }; + RawResponse { + status_line, + headers, + body, + } + }) + .await + .expect("exchange timed out") +} + +impl Wire { + async fn read_to_eof(&mut self) -> Vec { + let mut tmp = [0u8; 8192]; + loop { + match self.stream.read(&mut tmp).await { + Ok(0) | Err(_) => break, + Ok(n) => self.buf.extend_from_slice(&tmp[..n]), + } + } + std::mem::take(&mut self.buf) + } +} + +/// Spawn the in-test raw upstream fixture (capture + scripted verbatim +/// response) and return (port, captured-requests handle). +fn spawn_upstream( + response: &'static [u8], +) -> ( + u16, + std::sync::Arc>>, +) { + let captured = std::sync::Arc::new(tokio::sync::Mutex::new(Vec::new())); + let cap = std::sync::Arc::clone(&captured); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let listener = tokio::net::TcpListener::from_std(listener).unwrap(); + loop { + let (stream, _) = listener.accept().await.unwrap(); + let cap = std::sync::Arc::clone(&cap); + tokio::spawn(async move { + let mut wire = Wire::new(stream); + let req = read_request(&mut wire).await; + cap.lock().await.push(req); + wire.write_all(response).await; + }); + } + }); + (port, captured) +} + +// ── The outer test ───────────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn browser01_proxy_through_the_real_binary() { + let server_binary = discover_server_binary(); + let home = tempfile::tempdir().expect("create temp home"); + let port = allocate_ephemeral_port(); + let token = format!("browser01-outer-test-secret-{}", std::process::id()); + + let mut child = Command::new(&server_binary) + .env("PORT", port.to_string()) + .env("AUTH_TOKEN", &token) + .env("FRESHELL_BIND_HOST", "127.0.0.1") + .env("HOME", home.path()) + .env("FRESHELL_HOME", home.path()) + .env_remove("RUST_LOG") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn freshell-server"); + + let healthy = wait_for_health(port, &mut child, Duration::from_secs(30)).await; + if !healthy { + let stderr = drain_stderr(&mut child); + let _ = child.kill(); + let _ = child.wait(); + panic!("freshell-server never became healthy on port {port}; stderr:\n{stderr}"); + } + + // From here on the guard owns teardown: any panic in the flow still + // kills and reaps the child. + let _guard = ChildGuard(child); + run_proxy_flow(port, &token).await; +} + +async fn run_proxy_flow(port: u16, token: &str) { + // (1) GET: iframe-blocking headers removed, everything else survives — + // multi set-cookie (G2), content-length (G4), custom headers. + let (upstream_port, captured) = spawn_upstream( + b"HTTP/1.1 200 OK\r\n\ + content-type: text/html\r\n\ + content-length: 12\r\n\ + x-frame-options: DENY\r\n\ + content-security-policy: frame-ancestors 'none'\r\n\ + content-security-policy-report-only: default-src 'self'\r\n\ + set-cookie: session=abc; Path=/\r\n\ + set-cookie: prefs=dark; Path=/\r\n\ + x-upstream-marker: through-real-binary\r\n\r\n\ +

hi!

", + ); + + let resp = raw_exchange( + port, + format!( + "GET /api/proxy/http/{upstream_port}/ HTTP/1.1\r\n\ + host: 127.0.0.1:{port}\r\n\ + x-auth-token: {token}\r\n\ + cookie: freshell-auth={token}; app=kept\r\n\ + connection: close\r\n\r\n" + ) + .as_bytes(), + ) + .await; + assert_eq!(resp.status_code(), 200, "proxied GET status"); + // The proxy gate's own credentials never cross to the upstream + // (wrap-review r3, both servers): the captured upstream request carries + // neither `x-auth-token` nor the `freshell-auth` cookie pair — while + // the proxied app's own cookie survives. + { + let got = captured.lock().await; + let upstream_req = got + .iter() + .find(|r| r.raw_target() == "/") + .expect("upstream captured the GET"); + assert!( + !upstream_req + .headers + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("x-auth-token")), + "x-auth-token leaked upstream: {:?}", + upstream_req.headers + ); + let cookie = upstream_req + .headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("cookie")) + .map(|(_, v)| v.as_str()); + assert_eq!( + cookie, + Some("app=kept"), + "freshell-auth pair filtered, app cookie preserved" + ); + } + assert_eq!(resp.body, b"

hi!

"); + assert_eq!(resp.header_values("x-frame-options").count(), 0); + assert_eq!(resp.header_values("content-security-policy").count(), 0); + assert_eq!( + resp.header_values("content-security-policy-report-only") + .count(), + 0 + ); + assert_eq!( + resp.header_values("set-cookie").collect::>(), + vec!["session=abc; Path=/", "prefs=dark; Path=/"], + "BOTH set-cookie headers survive through the real binary" + ); + assert_eq!( + resp.header_values("content-length").collect::>(), + vec!["12"], + "content-length survives through the real binary" + ); + assert_eq!( + resp.header_values("x-upstream-marker").collect::>(), + vec!["through-real-binary"] + ); + + // (2) POST: raw path+query byte-exact (G1), body byte-exact with the + // original content-length framing (G3-through-real-server). + let (upstream2, captured2) = spawn_upstream(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok"); + let body = b"{\"a\": 1, \"b\": [true, null]}"; + let resp = raw_exchange(port, &{ + let mut req = format!( + "POST /api/proxy/http/{upstream2}/a%2Fb/c%20d?q=%2F&n=1+2 HTTP/1.1\r\n\ + host: 127.0.0.1:{port}\r\n\ + x-auth-token: {token}\r\n\ + content-type: application/json\r\n\ + content-length: {}\r\n\ + connection: close\r\n\r\n", + body.len() + ) + .into_bytes(); + req.extend_from_slice(body); + req + }) + .await; + assert_eq!(resp.status_code(), 200, "proxied POST status"); + let got = captured2.lock().await; + assert_eq!(got.len(), 1); + assert_eq!( + got[0].raw_target(), + "/a%2Fb/c%20d?q=%2F&n=1+2", + "raw path+query must reach the upstream byte-exact through the real binary" + ); + assert_eq!(got[0].body, body, "post body byte-exact"); + assert_eq!( + header_values(&got[0].headers, "content-length").collect::>(), + vec![body.len().to_string()], + "original content-length framing preserved" + ); + assert!(got[0].request_line.starts_with("POST ")); + drop(got); + + // (3) Legacy error shapes through the mounted app (404 fallback never + // interferes: these come from the proxy handler itself). + // 401 missing token: + let resp = raw_exchange( + port, + format!( + "GET /api/proxy/http/{upstream2}/ HTTP/1.1\r\n\ + host: 127.0.0.1:{port}\r\n\ + connection: close\r\n\r\n" + ) + .as_bytes(), + ) + .await; + assert_eq!(resp.status_code(), 401); + assert_eq!( + String::from_utf8(resp.body).unwrap(), + "{\"error\":\"Unauthorized\"}" + ); + + // 400 invalid port: + let resp = raw_exchange( + port, + format!( + "GET /api/proxy/http/99999/x HTTP/1.1\r\n\ + host: 127.0.0.1:{port}\r\n\ + x-auth-token: {token}\r\n\ + connection: close\r\n\r\n" + ) + .as_bytes(), + ) + .await; + assert_eq!(resp.status_code(), 400); + assert_eq!( + String::from_utf8(resp.body).unwrap(), + "{\"error\":\"Invalid port number\"}" + ); + + // 502 upstream connection refused: + let closed = allocate_ephemeral_port(); // bind-then-drop → nobody listening + let resp = raw_exchange( + port, + format!( + "GET /api/proxy/http/{closed}/x HTTP/1.1\r\n\ + host: 127.0.0.1:{port}\r\n\ + x-auth-token: {token}\r\n\ + connection: close\r\n\r\n" + ) + .as_bytes(), + ) + .await; + assert_eq!(resp.status_code(), 502); + assert_eq!( + String::from_utf8(resp.body).unwrap(), + format!("{{\"error\":\"Failed to connect to localhost:{closed}\"}}") + ); + + // Sanity: the capture fixture from (1) saw exactly one request and the + // proxied request carried method + host rewrite. + let got1 = captured.lock().await; + assert_eq!(got1.len(), 1); + assert!(got1[0].request_line.starts_with("GET / ")); + assert_eq!( + header_values(&got1[0].headers, "host").collect::>(), + vec![format!("127.0.0.1:{upstream_port}")] + ); +} diff --git a/crates/freshell-server/tests/diag01_lifecycle_logging.rs b/crates/freshell-server/tests/diag01_lifecycle_logging.rs new file mode 100644 index 000000000..4fbd6d285 --- /dev/null +++ b/crates/freshell-server/tests/diag01_lifecycle_logging.rs @@ -0,0 +1,662 @@ +//! DIAG-01 outer acceptance test (black-box, operator-experience): boots the +//! REAL `freshell-server` binary against an isolated temp home + ephemeral +//! loopback port, drives the flows the checklist names (auth, terminal, +//! provider, recoverable error, restart, quit), then parses EVERY line of +//! the on-disk JSONL log and asserts the full required-field schema plus +//! coherent correlation ids. +//! +//! This complements `diag01_diag03_logging.rs` (rotation/redaction under a +//! tiny cap -- deliberately NOT set here, so nothing rotates away) and the +//! in-crate span tests (`freshell-ws/tests/diag01_lifecycle_events.rs`) by +//! proving the assembled binary produces a spec-conformant log end to end. +//! +//! Harness conventions are intentionally duplicated from +//! `safe11_term22_shutdown_reaping.rs` / `diag01_diag03_logging.rs` +//! (this repo's black-box test files each carry their own small copy). + +use std::io::Read; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use futures_util::{SinkExt, StreamExt}; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +const AUTH_TOKEN: &str = "diag01-lifecycle-outer-test-secret-9f27c1"; + +type WsStream = + tokio_tungstenite::WebSocketStream>; + +// ── binary + boot harness ──────────────────────────────────────────────── + +fn discover_server_binary() -> PathBuf { + if let Some(explicit) = std::env::var_os("FRESHELL_SERVER_BIN") { + return PathBuf::from(explicit); + } + let suffix = std::env::consts::EXE_SUFFIX; + if let Some(found) = find_sibling(suffix) { + return found; + } + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let status = Command::new(env!("CARGO")) + .args(["build", "--bin", "freshell-server"]) + .current_dir(&manifest_dir) + .status() + .expect("spawn `cargo build --bin freshell-server`"); + assert!(status.success(), "cargo build --bin freshell-server failed"); + find_sibling(suffix).expect("freshell-server binary not found even after building it") +} + +fn find_sibling(suffix: &str) -> Option { + let exe = std::env::current_exe().ok()?; + for dir in exe.ancestors().skip(1).take(3) { + let candidate = dir.join(format!("freshell-server{suffix}")); + if candidate.exists() { + return Some(candidate); + } + } + None +} + +fn allocate_ephemeral_port() -> u16 { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port"); + listener.local_addr().expect("local_addr").port() +} + +async fn wait_for_health(port: u16, child: &mut Child, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + let url = format!("http://127.0.0.1:{port}/api/health"); + while Instant::now() < deadline { + if let Ok(Some(_)) = child.try_wait() { + return false; + } + if let Ok(resp) = reqwest::Client::new().get(&url).send().await { + if resp.status().is_success() { + return true; + } + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + false +} + +fn drain_stderr(child: &mut Child) -> String { + let mut buf = String::new(); + if let Some(stderr) = child.stderr.as_mut() { + let _ = stderr.read_to_string(&mut buf); + } + buf +} + +/// The committed fake codex app-server fixture (same `CODEX_CMD` mechanism +/// `safe11_term22_shutdown_reaping.rs` uses). +fn fake_codex_app_server_cmd() -> String { + format!( + "{}/../../test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs", + env!("CARGO_MANIFEST_DIR") + ) +} + +/// Seed `/.freshell/config.json` with `freshAgent.enabled: true` +/// before boot (the create gate; an untouched temp home defaults it false). +fn seed_fresh_agent_enabled(home: &std::path::Path) { + let dir = home.join(".freshell"); + std::fs::create_dir_all(&dir).expect("create .freshell dir"); + std::fs::write( + dir.join("config.json"), + serde_json::json!({ "settings": { "freshAgent": { "enabled": true } } }).to_string(), + ) + .expect("seed config.json"); +} + +struct Boot { + child: Child, + port: u16, +} + +async fn boot_server(server_binary: &std::path::Path, home: &std::path::Path) -> Boot { + let port = allocate_ephemeral_port(); + seed_fresh_agent_enabled(home); + let mut child = Command::new(server_binary) + .env("PORT", port.to_string()) + .env("AUTH_TOKEN", AUTH_TOKEN) + .env("FRESHELL_BIND_HOST", "127.0.0.1") + .env("FRESHELL_HOME", home) + .env("HOME", home) + .env("CODEX_CMD", format!("node {}", fake_codex_app_server_cmd())) + .env_remove("FAKE_CODEX_APP_SERVER_BEHAVIOR") + // The version stamps under test must come from the build constant: + // an ambient FRESHELL_APP_VERSION on the test host must not leak in + // and make the assertions compare against the wrong expectation. + .env_remove("FRESHELL_APP_VERSION") + .env_remove("RUST_LOG") + // Same ambient-hygiene class for the log SINK: the assertions below + // pin the default `/.freshell/logs/rust-server.jsonl` path and + // expect no rotation, so ambient log-dir/rotation overrides from the + // test host would otherwise fail the test for the wrong reason. + .env_remove("FRESHELL_LOG_DIR") + .env_remove("FRESHELL_LOG_MAX_BYTES") + .env_remove("FRESHELL_LOG_MAX_BACKUPS") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn freshell-server"); + let healthy = wait_for_health(port, &mut child, Duration::from_secs(20)).await; + if !healthy { + let _ = child.kill(); + let _ = child.wait(); + let stderr = drain_stderr(&mut child); + panic!("freshell-server never became healthy on port {port}; stderr:\n{stderr}"); + } + Boot { child, port } +} + +/// SIGTERM and require exit code 0 within 5s (the graceful-shutdown +/// contract; same shape as safe11's wait). +async fn sigterm_and_reap(boot: &mut Boot) { + let kill_rc = unsafe { libc::kill(boot.child.id() as libc::pid_t, libc::SIGTERM) }; + assert_eq!(kill_rc, 0, "SIGTERM to the server pid must succeed"); + let deadline = Instant::now() + Duration::from_secs(5); + let status = loop { + match boot.child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => { + if Instant::now() >= deadline { + let _ = boot.child.kill(); + let _ = boot.child.wait(); + panic!("server did not exit within 5s of SIGTERM"); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + Err(e) => panic!("try_wait failed: {e}"), + } + }; + assert!( + status.success(), + "server must exit 0 on SIGTERM (graceful), got {status:?}" + ); +} + +// ── ws helpers ─────────────────────────────────────────────────────────── + +async fn send_json(ws: &mut WsStream, value: &serde_json::Value) { + ws.send(WsMessage::Text(value.to_string())) + .await + .expect("ws send"); +} + +async fn wait_for_any_message_type( + ws: &mut WsStream, + type_names: &[&str], + timeout: Duration, +) -> Option<(String, serde_json::Value)> { + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return None; + } + match tokio::time::timeout(remaining, ws.next()).await { + Ok(Some(Ok(WsMessage::Text(text)))) => { + if let Ok(value) = serde_json::from_str::(&text) { + if let Some(got_type) = value.get("type").and_then(|t| t.as_str()) { + if type_names.contains(&got_type) { + return Some((got_type.to_string(), value)); + } + } + } + } + Ok(Some(Ok(_))) => continue, + Ok(Some(Err(_))) | Ok(None) => return None, + Err(_) => return None, + } + } +} + +// ── log parsing + schema assertions ───────────────────────────────────── + +struct LogLine { + index: usize, + value: serde_json::Value, +} + +fn parse_log(home: &std::path::Path) -> (String, Vec) { + let log_path = home.join(".freshell/logs/rust-server.jsonl"); + let raw = std::fs::read_to_string(&log_path) + .unwrap_or_else(|e| panic!("read {}: {e}", log_path.display())); + let mut lines = Vec::new(); + for (index, line) in raw.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let value: serde_json::Value = serde_json::from_str(line) + .unwrap_or_else(|e| panic!("log line {index} is not valid JSON: {e}\nline: {line}")); + lines.push(LogLine { index, value }); + } + (raw, lines) +} + +/// Assert the DIAG-01 per-line required-field schema on one parsed line. +fn assert_line_schema(line: &LogLine, expected_version: &str, expected_pid: u32, raw_line: &str) { + let v = &line.value; + let ctx = || { + format!( + "line {} ({}): {raw_line}", + line.index, + v["msg"].as_str().unwrap_or("?") + ) + }; + let ts = v["ts"] + .as_str() + .unwrap_or_else(|| panic!("{}: missing ts", ctx())); + assert!( + chrono::DateTime::parse_from_rfc3339(ts).is_ok(), + "{}: ts is not RFC3339: {ts}", + ctx() + ); + let level = v["level"] + .as_str() + .unwrap_or_else(|| panic!("{}: missing level", ctx())); + assert!( + ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"].contains(&level), + "{}: unexpected level {level}", + ctx() + ); + assert!( + v["target"].as_str().map(|t| !t.is_empty()).unwrap_or(false), + "{}: missing/empty target (component)", + ctx() + ); + assert!(v["msg"].is_string(), "{}: msg must be a string", ctx()); + assert_eq!( + v["app_version"].as_str(), + Some(expected_version), + "{}: app_version must be stamped on every line", + ctx() + ); + assert_eq!( + v["server_pid"].as_u64(), + Some(expected_pid as u64), + "{}: server_pid must be stamped on every line", + ctx() + ); +} + +fn find_line<'a>(lines: &'a [LogLine], msg: &str) -> Option<&'a LogLine> { + lines.iter().find(|l| l.value["msg"].as_str() == Some(msg)) +} + +fn find_all<'a>(lines: &'a [LogLine], msg: &str) -> Vec<&'a LogLine> { + lines + .iter() + .filter(|l| l.value["msg"].as_str() == Some(msg)) + .collect() +} + +// ── test 1: full flows + schema + correlation ──────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn diag01_full_flow_log_schema_and_correlation() { + let server_binary = discover_server_binary(); + let home = tempfile::tempdir().expect("create temp home"); + let mut boot = boot_server(&server_binary, home.path()).await; + let server_pid = boot.child.id(); + let base = format!("http://127.0.0.1:{}", boot.port); + let client = reqwest::Client::new(); + + // ── auth flow: bad token 401, good token 200 (HTTP level) ── + let bad = client + .get(format!("{base}/api/settings")) + .header("x-auth-token", "definitely-the-wrong-token") + .send() + .await + .expect("bad-token request"); + assert_eq!(bad.status().as_u16(), 401, "wrong token must 401"); + let ok = client + .get(format!("{base}/api/settings")) + .header("x-auth-token", AUTH_TOKEN) + .send() + .await + .expect("good-token request"); + assert!(ok.status().is_success(), "good token must succeed"); + + // The reported version, to cross-check the log's app_version stamp. + let version_resp = client + .get(format!("{base}/api/version")) + .header("x-auth-token", AUTH_TOKEN) + .send() + .await + .expect("version request"); + let reported_version = version_resp + .json::() + .await + .expect("version json")["currentVersion"] + .as_str() + .expect("currentVersion string") + .to_string(); + + // ── recoverable-error flow: authenticated 404 ── + let not_found = client + .get(format!( + "{base}/api/session-directory/definitely-missing-id-diag01" + )) + .header("x-auth-token", AUTH_TOKEN) + .send() + .await + .expect("404 request"); + assert_eq!(not_found.status().as_u16(), 404); + + // ── WS flow: hello -> ready; ping -> pong ── + let ws_url = format!("ws://127.0.0.1:{}/ws", boot.port); + let (mut ws, _resp) = tokio_tungstenite::connect_async(&ws_url) + .await + .expect("ws connect"); + send_json( + &mut ws, + &serde_json::json!({ + "type": "hello", + "protocolVersion": freshell_protocol::WS_PROTOCOL_VERSION, + "token": AUTH_TOKEN, + }), + ) + .await; + wait_for_any_message_type(&mut ws, &["ready"], Duration::from_secs(5)) + .await + .expect("expected `ready` handshake frame"); + send_json(&mut ws, &serde_json::json!({ "type": "ping" })).await; + let pong = wait_for_any_message_type(&mut ws, &["pong"], Duration::from_secs(5)) + .await + .expect("expected a `pong` reply"); + assert!(pong.1["timestamp"].is_string(), "pong carries a timestamp"); + + // ── terminal flow: create -> kill ── + let term_rid = format!("diag01-term-{}", uuid::Uuid::new_v4()); + send_json( + &mut ws, + &serde_json::json!({ + "type": "terminal.create", + "requestId": term_rid, + "mode": "shell", + "shell": "system", + }), + ) + .await; + let created = wait_for_any_message_type( + &mut ws, + &["terminal.created", "error"], + Duration::from_secs(15), + ) + .await + .expect("expected terminal.created"); + assert_eq!( + created.0, "terminal.created", + "create must succeed: {created:?}" + ); + let terminal_id = created.1["terminalId"] + .as_str() + .expect("terminalId") + .to_string(); + send_json( + &mut ws, + &serde_json::json!({ "type": "terminal.kill", "terminalId": terminal_id }), + ) + .await; + // The kill's `terminal.exit` wire frame fans out to ATTACHED viewers + // only, and this test never attaches — the artifact under test is the + // LOG, so wait for the `terminal.killed` JSONL event directly (the + // writer flushes synchronously per line). + let log_for_kill = home.path().join(".freshell/logs/rust-server.jsonl"); + let kill_deadline = Instant::now() + Duration::from_secs(5); + loop { + let have_kill = std::fs::read_to_string(&log_for_kill) + .map(|content| { + content.lines().any(|line| { + line.contains("\"terminal.killed\"") && line.contains(terminal_id.as_str()) + }) + }) + .unwrap_or(false); + if have_kill || Instant::now() >= kill_deadline { + assert!(have_kill, "terminal.killed never reached the log"); + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // ── provider flow: freshcodex session against the fake sidecar ── + let agent_rid = format!("diag01-agent-{}", uuid::Uuid::new_v4()); + send_json( + &mut ws, + &serde_json::json!({ + "type": "freshAgent.create", + "requestId": agent_rid, + "sessionType": "freshcodex", + "provider": "codex", + }), + ) + .await; + match wait_for_any_message_type( + &mut ws, + &["freshAgent.created", "freshAgent.createFailed"], + Duration::from_secs(50), + ) + .await + { + Some((got_type, _)) if got_type == "freshAgent.created" => {} + other => panic!("expected freshAgent.created, got {other:?}"), + } + + // Clean close, then the quit flow. + ws.close(None).await.ok(); + tokio::time::sleep(Duration::from_millis(300)).await; + sigterm_and_reap(&mut boot).await; + + // ── parse + assert ── + let (raw, lines) = parse_log(home.path()); + assert!(!lines.is_empty(), "log must not be empty"); + let raw_lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect(); + for (line, raw_line) in lines.iter().zip(raw_lines.iter()) { + assert_line_schema(line, &reported_version, server_pid, raw_line); + } + assert!( + !raw.contains(AUTH_TOKEN), + "the real AUTH_TOKEN value leaked into the log" + ); + + // Lifecycle: exactly one coherent start/stop chain, in order. + let started = find_line(&lines, "server.started").expect("server.started must be logged"); + for field in ["bind", "port", "boot_id", "instance_id", "commit", "dirty"] { + assert!( + started.value.get(field).is_some(), + "server.started must carry {field}" + ); + } + let stopping = find_line(&lines, "server.stopping").expect("server.stopping must be logged"); + assert_eq!(stopping.value["signal"].as_str(), Some("SIGTERM")); + // The forensics record carries `event = "shutdown_forensics"` (its msg + // is prose), so match on the event field, not msg. + assert!( + lines + .iter() + .any(|l| l.value["event"].as_str() == Some("shutdown_forensics")), + "shutdown_forensics event must be present between stopping and stopped" + ); + let stopped = find_line(&lines, "server.stopped").expect("server.stopped must be logged"); + assert!( + started.index < stopping.index && stopping.index < stopped.index, + "lifecycle order must be started < stopping < stopped ({} < {} < {})", + started.index, + stopping.index, + stopped.index + ); + + // Correlation: one connection id threads established -> terminal.created + // -> closed; one terminal id threads created -> killed. + let established = find_line(&lines, "ws.connection.established") + .expect("ws.connection.established must be logged"); + let conn_id = established.value["connection_id"] + .as_u64() + .expect("connection_id integer"); + let closed = + find_line(&lines, "ws.connection.closed").expect("ws.connection.closed must be logged"); + assert_eq!(closed.value["connection_id"].as_u64(), Some(conn_id)); + assert!( + closed.value["reason"] + .as_str() + .map(|r| !r.is_empty()) + .unwrap_or(false), + "connection.closed carries a reason" + ); + + let term_created = + find_line(&lines, "terminal.created").expect("terminal.created event must be logged"); + assert_eq!( + term_created.value["terminal_id"].as_str(), + Some(terminal_id.as_str()) + ); + assert!( + term_created.value["pid"].as_u64().unwrap_or(0) > 0, + "terminal.created carries the child pid (process ownership)" + ); + assert_eq!( + term_created.value["connection_id"].as_u64(), + Some(conn_id), + "terminal.created must inherit the serving connection's id" + ); + let term_killed = + find_line(&lines, "terminal.killed").expect("terminal.killed event must be logged"); + assert_eq!( + term_killed.value["terminal_id"].as_str(), + Some(terminal_id.as_str()) + ); + + let agent_created = find_line(&lines, "freshagent.session.created") + .expect("freshagent.session.created must be logged"); + assert_eq!(agent_created.value["provider"].as_str(), Some("codex")); + assert!( + agent_created.value["session_id"] + .as_str() + .map(|s| !s.is_empty()) + .unwrap_or(false), + "freshagent.session.created carries a session_id" + ); + // The sidecar spawn event: the schema documents `pid` (the process just + // created) and static `provider` -- never a prompt or token. The + // pid<->session join is the same session's adjacent created/reaped + // events (spawned can precede session minting on the create path). + let sidecar = find_line(&lines, "freshagent.sidecar.spawned") + .expect("freshagent.sidecar.spawned must be logged"); + assert!( + sidecar.value["pid"].as_u64().unwrap_or(0) > 0, + "sidecar.spawned carries the spawned process pid" + ); + assert_eq!( + sidecar.value["provider"].as_str(), + Some("codex"), + "sidecar.spawned carries its provider" + ); + + // Error entries: warn-level, route-correlated, distinct request ids. + let http_events = find_all(&lines, "http_request"); + let unauth = http_events + .iter() + .find(|l| { + l.value["status"].as_u64() == Some(401) + && l.value["route"].as_str() == Some("/api/settings") + }) + .expect("a warn-level 401 /api/settings entry"); + assert_eq!(unauth.value["level"].as_str(), Some("WARN")); + let four_oh_four = http_events + .iter() + .find(|l| { + l.value["status"].as_u64() == Some(404) + && l.value["route"] + .as_str() + .map(|r| r.contains("session-directory")) + .unwrap_or(false) + }) + .expect("a 404 session-directory entry"); + let rid_401 = unauth.value["request_id"].as_str().expect("401 request_id"); + let rid_404 = four_oh_four.value["request_id"] + .as_str() + .expect("404 request_id"); + assert_ne!(rid_401, rid_404, "request ids must be distinct per request"); +} + +// ── test 2: restart writes two coherent lifecycles into one log ───────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn diag01_restart_writes_two_coherent_lifecycles() { + let server_binary = discover_server_binary(); + let home = tempfile::tempdir().expect("create temp home"); + + let mut boot_a = boot_server(&server_binary, home.path()).await; + let pid_a = boot_a.child.id(); + sigterm_and_reap(&mut boot_a).await; + + let mut boot_b = boot_server(&server_binary, home.path()).await; + let pid_b = boot_b.child.id(); + sigterm_and_reap(&mut boot_b).await; + + assert_ne!(pid_a, pid_b, "two boots are two processes"); + + let (raw, lines) = parse_log(home.path()); + let raw_lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect(); + // The /api/version cross-check needs a live server, so assert the stamp + // against the build's own APP_VERSION here ("0.7.0") -- test 1 already + // proves the stamp equals the reported version; here we assert the + // per-boot pid attribution, which is the restart-specific property. + for (line, raw_line) in lines.iter().zip(raw_lines.iter()) { + assert_line_schema( + line, + "0.7.0", + line.value["server_pid"].as_u64().unwrap_or(0) as u32, + raw_line, + ); + } + + let starteds = find_all(&lines, "server.started"); + let stoppeds = find_all(&lines, "server.stopped"); + let stoppings = find_all(&lines, "server.stopping"); + assert_eq!(starteds.len(), 2, "two boots -> two server.started events"); + assert_eq!(stoppeds.len(), 2, "two boots -> two server.stopped events"); + assert_eq!( + stoppings.len(), + 2, + "two boots -> two server.stopping events" + ); + + let (a_start, b_start) = (starteds[0], starteds[1]); + let (a_stop, b_stop) = (stoppeds[0], stoppeds[1]); + assert!( + a_start.index < a_stop.index + && a_stop.index < b_start.index + && b_start.index < b_stop.index, + "lifecycles must interleave cleanly: A.started < A.stopped < B.started < B.stopped" + ); + + // Same persistent installation identity, distinct per-boot identity. + assert_eq!( + a_start.value["instance_id"], b_start.value["instance_id"], + "instance_id persists per home (CFG-07)" + ); + assert_ne!( + a_start.value["boot_id"], b_start.value["boot_id"], + "boot_id is per-boot" + ); + assert_eq!(a_start.value["server_pid"].as_u64(), Some(pid_a as u64)); + assert_eq!(b_start.value["server_pid"].as_u64(), Some(pid_b as u64)); + + // Same version across boots; timestamps non-decreasing in file order + // (append-only chronological stream). + assert_eq!(a_start.value["app_version"], b_start.value["app_version"]); + let mut prev: Option> = None; + for line in &lines { + let ts = chrono::DateTime::parse_from_rfc3339(line.value["ts"].as_str().unwrap()) + .expect("RFC3339 ts"); + if let Some(prev) = prev { + assert!(ts >= prev, "log timestamps must be non-decreasing"); + } + prev = Some(ts); + } +} diff --git a/crates/freshell-server/tests/net09_config_preservation.rs b/crates/freshell-server/tests/net09_config_preservation.rs index 0d4b083f2..5fe100f74 100644 --- a/crates/freshell-server/tests/net09_config_preservation.rs +++ b/crates/freshell-server/tests/net09_config_preservation.rs @@ -93,6 +93,16 @@ async fn network_mutation_preserves_every_unmanaged_top_level_key() { "completedMigrations": ["m-001", "m-002", "ai-title-shadow-cleanup"], "recentDirectories": ["/tmp/a", "/tmp/b"], "projectColors": { "/tmp/a": "#123456" }, + // CFG-01 sentinel breadth: the CFG-04 owned seed key must also be + // byte-preserved through the network writer (and the no-op-boot + // restart leg below). + "legacyLocalSettingsSeed": { + "theme": "light", + "uiScale": 1.25, + "terminal": { "fontSize": 18, "fontFamily": "Net09 Sentinel Mono" }, + "sidebar": { "sortMode": "project", "width": 280, "collapsed": true }, + "notifications": { "soundEnabled": false } + }, "someUnknownFutureKey": { "arbitrary": [1, 2, 3] } }); let cfg_dir = home.path().join(".freshell"); @@ -112,6 +122,7 @@ async fn network_mutation_preserves_every_unmanaged_top_level_key() { "completedMigrations", "recentDirectories", "projectColors", + "legacyLocalSettingsSeed", "someUnknownFutureKey", ]; let before: std::collections::HashMap<_, _> = watched diff --git a/crates/freshell-sessions/tests/malformed_data_quarantine.rs b/crates/freshell-sessions/tests/malformed_data_quarantine.rs new file mode 100644 index 000000000..25846e84a --- /dev/null +++ b/crates/freshell-sessions/tests/malformed_data_quarantine.rs @@ -0,0 +1,878 @@ +//! SESSION-16 — "Tolerate malformed and partially written provider data." +//! +//! Integration pins over the REAL `SessionIndex` + REAL `SessionSource` impls +//! (`ClaudeSource` / `CodexSource` / `AmplifierSource` / `OpencodeSource`) against real +//! on-disk corpora, proving the three acceptance clauses at the index seam: +//! +//! 1. **Healthy sessions stay available** — quarantine-class records (empty, +//! all-malformed, cwd-invisible from parse) are excluded per-record; sibling healthy +//! records are indexed and stable no matter what sits next to them. For the +//! single-db provider (OpenCode) a corrupt database records a scan failure and +//! preserves the healthy cached sessions instead of serving a silent healthy-empty. +//! 2. **Bad records are quarantined** — they never appear in a snapshot, and a cached +//! exclusion is never re-parsed while the file's `(mtime, size)` sits unchanged. +//! 3. **A record is indexed once it becomes valid** — a partially-written record +//! (truncated mid-line write, permanently corrupt first line followed by a valid +//! append, truncated-then-rewritten metadata doc) becomes indexed when its content +//! becomes parseable, WITHOUT a restart, because the exclusion cache entry is keyed +//! on `(mtime, size)` which the completing write moves. +//! +//! Parity source (frozen legacy `server/` at the base SHA): +//! `server/coding-cli/session-indexer.ts` `readLightweightMeta` (per-file/per-line +//! `try/catch { continue }`, `if (!meta.cwd) continue` R10b gate) + `providers/claude.ts` +//! `parseSessionContent` + `providers/codex.ts` (same per-line skip) + +//! `providers/opencode.ts` (`listSessionsDirect` re-throws read errors so the indexer +//! keeps previously-listed sessions; rows without a cwd are skipped) + +//! `providers/amplifier.ts` (`parseAmplifierMetadata` malformed → `{}` → cwd-less skip). +//! +//! INTENTIONALLY NOT quarantined (legacy parity — asserted as indexed below): +//! - invalid-UTF-8 transcripts (Node's `fs.readFile(f, 'utf8')` is lossy U+FFFD; the +//! record is indexed with replacement chars — regression class "bug #7"), +//! - truncated-with-valid-prefix records (the parseable prefix is indexed; the +//! truncated tail is skipped). +//! +//! Cross-checks for the audit ledger `docs/plans/df1/SESSION-16.md` (A1–A5): every test +//! here PASSES against the un-modified base implementation — these are characterization +//! pins of already-correct behavior (class-P item: behavior present, evidence missing), +//! each verified teeth-bearing by task-0 mutation spot-checks recorded in the evidence +//! file (`docs/plans/df1-evidence/SESSION-16.md`). + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use freshell_sessions::amplifier::AmplifierSource; +use freshell_sessions::directory_index::{ + ClaudeSource, CodexSource, FileStat, IndexedSession, OpencodeSource, SessionIndex, + SessionSource, +}; + +/// Short TTL so a next-`snapshot()` call almost immediately re-sweeps. +const TTL: Duration = Duration::from_millis(10); +/// Poll budget for observing a detached background sweep settle (stale-while-revalidate +/// means a post-TTL `snapshot()` returns the STALE generation while the re-sweep runs). +const SETTLE: Duration = Duration::from_secs(5); + +// ── fixtures/helpers ───────────────────────────────────────────────────────── + +/// A real temp dir that removes itself on drop (every corpus lives under one). +struct TmpDir(PathBuf); +impl TmpDir { + fn new(label: &str) -> Self { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "freshell-s16-{label}-{}-{nanos}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::SeqCst) + )); + std::fs::create_dir_all(&dir).unwrap(); + TmpDir(dir) + } + fn path(&self) -> &Path { + &self.0 + } +} +impl Drop for TmpDir { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).ok(); + } +} + +fn mk_index(sources: Vec>) -> SessionIndex { + // Persistence disabled: tests stay hermetic (no real `~/.freshell` cache file). + SessionIndex::with_ttl_and_cache_path(sources, TTL, None) +} + +/// Poll `snapshot()` until `pred` holds on a published generation or `timeout` elapses. +/// Each call past the TTL spawns a detached background sweep, so repeated polling is how +/// the (deliberately un-awaited) refresh is observed settling. +async fn poll_until( + index: &SessionIndex, + timeout: Duration, + mut pred: impl FnMut(&[IndexedSession]) -> bool, +) -> Option> { + let start = std::time::Instant::now(); + loop { + let snap = index.snapshot().await; + if pred(&snap) { + return Some(snap.to_vec()); + } + if start.elapsed() >= timeout { + return None; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + +/// One minimal valid claude record line (mirrors +/// `directory_index.rs`'s in-crate `write_session_file` shape: a single user record +/// carrying cwd + sessionId + a user message -> title). +fn claude_line(session_id: &str, cwd: &str, timestamp: &str, message: &str) -> String { + format!( + "{{\"parentUuid\":null,\"isSidechain\":false,\"userType\":\"external\",\"cwd\":\"{cwd}\",\"sessionId\":\"{session_id}\",\"version\":\"1.0.0\",\"gitBranch\":\"main\",\"type\":\"user\",\"message\":{{\"role\":\"user\",\"content\":\"{message}\"}},\"uuid\":\"{session_id}\",\"timestamp\":\"{timestamp}\"}}\n" + ) +} + +/// Canonical-looking claude session id (`is_canonical_claude_session_id` passes). +fn claude_id(n: usize) -> String { + format!("{n:08x}-0000-4000-8000-000000000000") +} + +fn keys(items: &[IndexedSession]) -> Vec { + items.iter().map(|s| s.key()).collect() +} + +/// Wraps a file-based source to count `parse()` calls — the quarantine-economics pin: +/// a cached exclusion must never be re-parsed while `(mtime, size)` hold. +struct CountParse { + inner: S, + parse_calls: Arc, +} +impl CountParse { + fn new(inner: S) -> (Self, Arc) { + let calls = Arc::new(AtomicUsize::new(0)); + ( + Self { + inner, + parse_calls: Arc::clone(&calls), + }, + calls, + ) + } +} +impl SessionSource for CountParse { + fn discover(&self) -> Vec { + self.inner.discover() + } + fn parse(&self, path: &Path) -> Option { + self.parse_calls.fetch_add(1, Ordering::SeqCst); + self.inner.parse(path) + } + fn provider_name(&self) -> Option<&'static str> { + self.inner.provider_name() + } + fn discover_checked(&self) -> Result, std::io::Error> { + self.inner.discover_checked() + } +} + +// ── claude ─────────────────────────────────────────────────────────────────── + +/// Clause 1+2 (claude): a healthy session stays indexed across every quarantine class +/// sitting next to it, and the corpus is STABLE across a second sweep (quarantined +/// records don't wobble in/out). +#[tokio::test] +async fn claude_healthy_session_survives_a_matrix_of_quarantined_siblings() { + let home = TmpDir::new("claude-matrix"); + let claude_home = home.path().join(".claude"); + let project = claude_home.join("projects").join("-p"); + std::fs::create_dir_all(&project).unwrap(); + + // The healthy session — present in every assertion below. + std::fs::write( + project.join(format!("{}.jsonl", claude_id(1))), + claude_line( + &claude_id(1), + "/p/healthy", + "2026-01-30T08:00:00.000Z", + "healthy request", + ), + ) + .unwrap(); + // (a) 0-byte file — never had a first write flush. + std::fs::write(project.join(format!("{}.jsonl", claude_id(2))), "").unwrap(); + // (b) whitespace-only. + std::fs::write( + project.join(format!("{}.jsonl", claude_id(3))), + "\n \n\r\n\t\n", + ) + .unwrap(); + // (c) every line malformed (mixed garbage shapes). + std::fs::write( + project.join(format!("{}.jsonl", claude_id(4))), + "not json at all\n{\"unclosed\":\n\x00\x01\x02 binary junk\n[1,2,\n", + ) + .unwrap(); + // (d) well-formed JSON lines but NO cwd anywhere (R10b discovery gate). + std::fs::write( + project.join(format!("{}.jsonl", claude_id(5))), + format!( + "{}\n{}\n", + "{\"type\":\"summary\",\"summary\":\"a cwd-less record\"}", + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hi\"}}" + ), + ) + .unwrap(); + // (e) truncated mid-line: the entire file is ONE incomplete JSON object. + let full = claude_line( + &claude_id(6), + "/p/truncated", + "2026-01-30T08:01:00.000Z", + "cut off", + ); + std::fs::write( + project.join(format!("{}.jsonl", claude_id(6))), + &full[..full.len() / 3], + ) + .unwrap(); + // (f) truncated-with-VALID-prefix: the cwd-bearing line survived; only the tail line + // is cut. NOT quarantined (legacy indexes the parseable prefix) — this is the + // "partially written but already useful" class. + let mut partial = claude_line( + &claude_id(7), + "/p/prefix", + "2026-01-30T08:02:00.000Z", + "prefix kept", + ); + let tail = claude_line( + &claude_id(7), + "/p/prefix", + "2026-01-30T08:03:00.000Z", + "cut tail", + ); + partial.push_str(&tail[..tail.len() / 3]); + std::fs::write(project.join(format!("{}.jsonl", claude_id(7))), partial).unwrap(); + + let index = mk_index(vec![Arc::new(ClaudeSource::new(claude_home.clone()))]); + + let snap = index.snapshot().await; + // Sort order is lastActivityAt DESC: (f) (08:02, only the prefix line parses) ranks + // above the healthy seed (08:00). Quarantine set (a)-(e) never appears. + assert_eq!( + keys(&snap), + vec![ + format!("claude:{}", claude_id(7)), + format!("claude:{}", claude_id(1)) + ], + "healthy + valid-prefix indexed; empty/whitespace/all-malformed/cwd-less/truncated-only quarantined" + ); + + // Clause-2 stability: a second sweep (past TTL, nothing changed) serves the same set. + let mut settled: Vec = snap.to_vec(); + assert!( + poll_until(&index, SETTLE, |items| { + let k = keys(items); + if k.len() == 2 && k == keys(&snap) { + settled = items.to_vec(); + true + } else { + false + } + }) + .await + .is_some(), + "quarantine matrix is stable across an unchanged re-sweep" + ); + assert_eq!(keys(&settled), keys(&snap)); +} + +/// Clause 1+2 (claude, LIVE source path): an invalid-UTF-8 transcript is NOT +/// quarantined — Node's `fs.readFile(f, 'utf8')` is lossy and the record is indexed with +/// U+FFFD replacement chars (regression class "bug #7": `read_to_string` previously +/// dropped the whole file). Pinned at the oracle seam by +/// `session_directory.rs::invalid_utf8_transcript_is_indexed_lossily_like_node`; this is +/// the LIVE `ClaudeSource`/`SessionIndex` equivalent plus a healthy sibling. +#[tokio::test] +async fn claude_invalid_utf8_record_is_indexed_lossily_not_quarantined() { + let home = TmpDir::new("claude-utf8"); + let claude_home = home.path().join(".claude"); + let project = claude_home.join("projects").join("-home-dan-proj"); + std::fs::create_dir_all(&project).unwrap(); + + // Invalid UTF-8 subsequences inside an otherwise-valid JSON record (same byte shape + // as the oracle-path regression test). + let mut bytes: Vec = Vec::new(); + bytes.extend_from_slice(br#"{"parentUuid":null,"cwd":"/home/dan/proj","sessionId":"cccc1111-2222-4333-8444-555566667777","type":"user","message":{"role":"user","content":"bad "#); + bytes.extend_from_slice(&[0xC3, 0x28, 0x20, 0xE2, 0x82, 0x20, 0xF0, 0x9F, 0x98]); + bytes.extend_from_slice(br#" end"},"uuid":"cccc0001-0000-4000-8000-000000000001","timestamp":"2026-01-30T08:00:00.000Z"}"#); + bytes.push(b'\n'); + std::fs::write( + project.join("cccc1111-2222-4333-8444-555566667777.jsonl"), + bytes, + ) + .unwrap(); + + // A healthy sibling must be unaffected by the corrupt neighbor. + std::fs::write( + project.join(format!("{}.jsonl", claude_id(42))), + claude_line( + &claude_id(42), + "/p/healthy", + "2026-01-30T09:00:00.000Z", + "healthy neighbor", + ), + ) + .unwrap(); + + let index = mk_index(vec![Arc::new(ClaudeSource::new(claude_home.clone()))]); + let snap = index.snapshot().await; + let mut k = keys(&snap); + k.sort(); + assert_eq!( + k, + vec![ + format!("claude:{}", claude_id(42)), + "claude:cccc1111-2222-4333-8444-555566667777".to_string() + ], + "invalid-UTF-8 record is indexed (lossy), healthy sibling intact" + ); + let lossy = snap + .iter() + .find(|s| s.session_id == "cccc1111-2222-4333-8444-555566667777") + .expect("lossy record present"); + let title = lossy.title.as_deref().unwrap_or(""); + assert!( + title.contains('\u{FFFD}'), + "title carries U+FFFD replacements (lossy read parity), got {title:?}" + ); + assert_eq!(lossy.cwd.as_deref(), Some("/home/dan/proj")); +} + +/// Clause 3 (claude): a partially-written record is quarantined only while it has no +/// parseable identity, and is indexed — WITHOUT restart — by exactly the completing +/// write. Two completion shapes: +/// (a) the line left truncated mid-write is itself completed by appending its missing +/// tail bytes (the true "completed partial write"), +/// (b) the first line stays corrupt forever (a crash mid-write) and a LATER complete +/// line supplies identity/cwd (append-only log reality). +#[tokio::test] +async fn claude_partial_record_is_indexed_once_it_becomes_valid() { + let home = TmpDir::new("claude-becomes-valid"); + let claude_home = home.path().join(".claude"); + let project = claude_home.join("projects").join("-p"); + std::fs::create_dir_all(&project).unwrap(); + + // Healthy sibling — the never-moving control. + std::fs::write( + project.join(format!("{}.jsonl", claude_id(10))), + claude_line( + &claude_id(10), + "/p/healthy", + "2026-01-30T08:00:00.000Z", + "healthy control", + ), + ) + .unwrap(); + + // (a) truncated mid-line — complete valid record exists only as prefix bytes. + let path_a = project.join(format!("{}.jsonl", claude_id(11))); + let full_a = claude_line( + &claude_id(11), + "/p/partial-a", + "2026-01-30T08:05:00.000Z", + "partial a", + ); + let cut_a = full_a.len() * 2 / 3; + std::fs::write(&path_a, &full_a[..cut_a]).unwrap(); + + // (b) first line permanently corrupt (mid-write crash shape). + let path_b = project.join(format!("{}.jsonl", claude_id(12))); + std::fs::write(&path_b, "{\"type\":\"user\",\"message\":{\"role\":\"use").unwrap(); + + let index = mk_index(vec![Arc::new(ClaudeSource::new(claude_home.clone()))]); + + let snap0 = index.snapshot().await; + assert_eq!( + keys(&snap0), + vec![format!("claude:{}", claude_id(10))], + "partial records quarantined while invalid, healthy sibling indexed" + ); + + // Complete (a): append exactly the missing tail bytes of the truncated line. + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&path_a) + .unwrap(); + use std::io::Write as _; + f.write_all(&full_a.as_bytes()[cut_a..]).unwrap(); + drop(f); + // Complete (b): the corrupt first line is never FIXED — it is TERMINATED (the `\n` + // of a later append lands) and a complete record arrives after it. Appending a bare + // line directly after the newline-less fragment would just extend the corrupt line + // forever; the newline-first append is the faithful healing shape for append-only + // JSONL providers. + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&path_b) + .unwrap(); + f.write_all(b"\n").unwrap(); + f.write_all( + claude_line( + &claude_id(12), + "/p/partial-b", + "2026-01-30T08:06:00.000Z", + "partial b", + ) + .as_bytes(), + ) + .unwrap(); + drop(f); + + let settled = poll_until(&index, SETTLE, |items| items.len() == 3) + .await + .expect("both completed partial records become indexed without a restart"); + let mut k = keys(&settled); + k.sort(); + assert_eq!( + k, + vec![ + format!("claude:{}", claude_id(10)), + format!("claude:{}", claude_id(11)), + format!("claude:{}", claude_id(12)) + ], + "exactly the two completed records join the index (one live addition each)" + ); + // The healthy control is byte-identical (same parsed fields) before and after. + let before = snap0 + .iter() + .find(|s| s.session_id == claude_id(10)) + .unwrap(); + let after = settled + .iter() + .find(|s| s.session_id == claude_id(10)) + .unwrap(); + assert_eq!( + before, after, + "healthy sibling record untouched by completions" + ); +} + +/// Clause-2 economics pin: quarantined records are cached as exclusions — an unchanged +/// corpus is never re-parsed, including the exclusions. +#[tokio::test] +async fn claude_exclusions_are_cached_and_never_reparsed_while_unchanged() { + let home = TmpDir::new("claude-exclusion-cache"); + let claude_home = home.path().join(".claude"); + let project = claude_home.join("projects").join("-p"); + std::fs::create_dir_all(&project).unwrap(); + + std::fs::write( + project.join(format!("{}.jsonl", claude_id(20))), + claude_line( + &claude_id(20), + "/p/healthy", + "2026-01-30T08:00:00.000Z", + "healthy", + ), + ) + .unwrap(); + std::fs::write(project.join(format!("{}.jsonl", claude_id(21))), "").unwrap(); + std::fs::write( + project.join(format!("{}.jsonl", claude_id(22))), + "garbage\n{not json\n", + ) + .unwrap(); + + let (source, parse_calls) = CountParse::new(ClaudeSource::new(claude_home.clone())); + let index = mk_index(vec![Arc::new(source)]); + + let snap = index.snapshot().await; + assert_eq!(snap.len(), 1); + assert_eq!( + parse_calls.load(Ordering::SeqCst), + 3, + "each file parsed once" + ); + + // Two settled sweeps later, still exactly 3 parse calls — neither the healthy entry + // nor the cached exclusions are re-parsed while (mtime, size) hold. + let mut last: Vec = snap.to_vec(); + for _ in 0..2 { + tokio::time::sleep(Duration::from_millis(40)).await; + last = index.snapshot().await.to_vec(); + tokio::time::sleep(Duration::from_millis(40)).await; // let the bg sweep publish + } + assert_eq!(last.len(), 1); + assert_eq!( + parse_calls.load(Ordering::SeqCst), + 3, + "no re-parse of healthy entry or cached exclusion while files are unchanged" + ); +} + +// ── codex ──────────────────────────────────────────────────────────────────── + +/// Codex rollout layout helpers: `/sessions/YYYY/MM/DD/.jsonl` +/// (recursive nesting; the source walks all of it). +fn write_codex_rollout(sessions_root: &Path, name: &str, lines: &str) { + let dir = sessions_root.join("2026").join("07").join("18"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(name), lines).unwrap(); +} + +/// The healthy codex rollout: `session_meta` (+cwd) and one user message (title/tier +/// data) — the same shape `session-directory-matrix.spec.ts` seeds. +fn codex_healthy(session_id: &str, cwd: &str) -> String { + [ + "{\"timestamp\":\"2026-07-18T08:00:00.000Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"" + .to_string() + + session_id + + "\",\"cwd\":\"" + + cwd + + "\"}}", + "{\"timestamp\":\"2026-07-18T08:00:01.000Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"codex healthy request\"}]}}".to_string(), + "{\"timestamp\":\"2026-07-18T08:00:02.000Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"codex healthy reply\"}]}}".to_string(), + ] + .join("\n") + + "\n" +} + +/// Clause 1+2 (codex): the same quarantine matrix as claude, provider-adjusted. +#[tokio::test] +async fn codex_healthy_session_survives_a_matrix_of_quarantined_siblings() { + let home = TmpDir::new("codex-matrix"); + let codex_home = home.path().join(".codex"); + let sessions = codex_home.join("sessions"); + std::fs::create_dir_all(&sessions).unwrap(); + + write_codex_rollout( + &sessions, + "codex-healthy-16.jsonl", + &codex_healthy("codex-healthy-16", "/p/codex-healthy"), + ); + // (a) 0-byte. + write_codex_rollout(&sessions, "codex-empty-16.jsonl", ""); + // (b) whitespace only. + write_codex_rollout(&sessions, "codex-ws-16.jsonl", "\n \r\n\n"); + // (c) all lines malformed. + write_codex_rollout( + &sessions, + "codex-garbage-16.jsonl", + "!!!\n{\"x\":\n\x00 junk\n", + ); + // (d) well-formed session_meta WITHOUT cwd (R10b). + write_codex_rollout( + &sessions, + "codex-cwdless-16.jsonl", + "{\"timestamp\":\"2026-07-18T08:00:00.000Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"codex-cwdless-16\"}}\n", + ); + // (e) truncated-only: the session_meta line (the FIRST line) cut mid-write — no + // complete line anywhere in the file. (Cutting across the multi-line document at a + // fixed fraction would leave line 1 intact, which is the valid-prefix class, NOT + // this class.) + let full = codex_healthy("codex-truncated-16", "/p/codex-truncated"); + let meta_line_end = full.find('\n').unwrap(); + let meta_line = &full[..meta_line_end]; + write_codex_rollout( + &sessions, + "codex-truncated-16.jsonl", + &meta_line[..meta_line.len() * 2 / 3], + ); + + let index = mk_index(vec![Arc::new(CodexSource::new(codex_home.clone()))]); + let snap = index.snapshot().await; + assert_eq!( + keys(&snap), + vec!["codex:codex-healthy-16".to_string()], + "only the healthy codex rollout is indexed; every quarantine class is excluded" + ); + + // Stable across a settled re-sweep. + assert!( + poll_until(&index, SETTLE, |items| items.len() == 1 + && items[0].session_id == "codex-healthy-16") + .await + .is_some(), + "codex quarantine matrix stable across an unchanged re-sweep" + ); +} + +/// Clause 3 (codex): a rollout whose `session_meta` line was flushed truncated mid-write +/// becomes indexed WITHOUT restart once the completing bytes land. +#[tokio::test] +async fn codex_partial_record_is_indexed_once_it_becomes_valid() { + let home = TmpDir::new("codex-becomes-valid"); + let codex_home = home.path().join(".codex"); + let sessions = codex_home.join("sessions"); + std::fs::create_dir_all(&sessions).unwrap(); + + write_codex_rollout( + &sessions, + "codex-control-16.jsonl", + &codex_healthy("codex-control-16", "/p/codex-control"), + ); + + // Partial: session_meta line cut mid-write (no trailing newline — the write stopped). + let meta_line = "{\"timestamp\":\"2026-07-18T09:00:00.000Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"codex-partial-16\",\"cwd\":\"/p/codex-partial\"}}\n"; + let cut = meta_line.len() * 2 / 3; + let partial_name = "codex-partial-16.jsonl"; + write_codex_rollout(&sessions, partial_name, &meta_line[..cut]); + + let index = mk_index(vec![Arc::new(CodexSource::new(codex_home.clone()))]); + let snap0 = index.snapshot().await; + assert_eq!( + keys(&snap0), + vec!["codex:codex-control-16".to_string()], + "truncated session_meta quarantined while invalid" + ); + + // The writer resumes: the remaining bytes of the SAME line land. + let target = sessions + .join("2026") + .join("07") + .join("18") + .join(partial_name); + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&target) + .unwrap(); + use std::io::Write as _; + f.write_all(&meta_line.as_bytes()[cut..]).unwrap(); + // A turn follows, as it would in a real rollout. + f.write_all(b"{\"timestamp\":\"2026-07-18T09:00:01.000Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"resumed codex request\"}]}}\n").unwrap(); + drop(f); + + let settled = poll_until(&index, SETTLE, |items| items.len() == 2) + .await + .expect("completed codex rollout becomes indexed without a restart"); + let mut k = keys(&settled); + k.sort(); + assert_eq!( + k, + vec![ + "codex:codex-control-16".to_string(), + "codex:codex-partial-16".to_string() + ], + "exactly one live addition: the completed rollout" + ); + let completed = settled + .iter() + .find(|s| s.session_id == "codex-partial-16") + .expect("completed record present"); + assert_eq!(completed.cwd.as_deref(), Some("/p/codex-partial")); +} + +// ── amplifier ──────────────────────────────────────────────────────────────── + +/// Amplifier session-dir writers: `/projects//sessions//metadata.json` +/// (+ optional sibling `transcript.jsonl` for the first-user-message preview). +fn write_amplifier_session(amp_home: &Path, id: &str, metadata: &str, transcript: Option<&str>) { + let dir = amp_home + .join("projects") + .join("s16-project") + .join("sessions") + .join(id); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("metadata.json"), metadata).unwrap(); + if let Some(t) = transcript { + std::fs::write(dir.join("transcript.jsonl"), t).unwrap(); + } +} + +fn amplifier_healthy_metadata(id: &str, working_dir: &str, name: &str) -> String { + format!( + "{{\"session_id\":\"{id}\",\"working_dir\":\"{working_dir}\",\"created\":\"2026-08-01T00:00:00.000Z\",\"description_updated_at\":\"2026-08-01T00:00:02.000Z\",\"name\":\"{name}\",\"description\":\"{name} summary\"}}" + ) +} + +/// Clause 1+2+3 (amplifier): quarantine classes for the metadata-doc provider, plus the +/// becomes-valid transition when a truncated `metadata.json` is completed (metadata.json +/// is (re)written whole by the provider, so completion = content replace, not append). +#[tokio::test] +async fn amplifier_healthy_survives_quarantined_siblings_and_partial_completes() { + let home = TmpDir::new("amplifier-matrix"); + let amp_home = home.path().join(".amplifier"); + + write_amplifier_session( + &_home, + "amp-healthy-16", + &lifier_healthy_metadata("amp-healthy-16", "/p/amp-healthy", "s16 amplifier healthy"), + Some("{\"role\":\"user\",\"content\":\"s16 amplifier healthy request\"}\n"), + ); + // Malformed metadata doc (`parseAmplifierMetadata` -> `{}` -> cwd-less -> skip). + write_amplifier_session(&_home, "amp-malformed-16", "{not json at all", None); + // Empty metadata doc. + write_amplifier_session(&_home, "amp-empty-16", "", None); + // Valid doc missing `working_dir` (R10b). + write_amplifier_session( + &_home, + "amp-cwdless-16", + "{\"session_id\":\"amp-cwdless-16\",\"name\":\"no working dir\"}", + None, + ); + // Partial: truncated mid-doc. + let full_partial = + amplifier_healthy_metadata("amp-partial-16", "/p/amp-partial", "s16 amplifier partial"); + let cut = full_partial.len() * 2 / 3; + write_amplifier_session(&_home, "amp-partial-16", &full_partial[..cut], None); + + let index = mk_index(vec![Arc::new(AmplifierSource::new(amp_home.clone()))]); + let snap0 = index.snapshot().await; + assert_eq!( + keys(&snap0), + vec!["amplifier:amp-healthy-16".to_string()], + "only the healthy amplifier session is indexed; malformed/empty/cwd-less/partial quarantined" + ); + + // The provider completes the partial metadata doc with a full rewrite. + write_amplifier_session(&_home, "amp-partial-16", &full_partial, None); + let settled = poll_until(&index, SETTLE, |items| items.len() == 2) + .await + .expect("completed amplifier metadata.json becomes indexed without a restart"); + let mut k = keys(&settled); + k.sort(); + assert_eq!( + k, + vec![ + "amplifier:amp-healthy-16".to_string(), + "amplifier:amp-partial-16".to_string() + ], + "exactly one live addition: the completed amplifier record" + ); + let completed = settled + .iter() + .find(|s| s.session_id == "amp-partial-16") + .expect("completed record present"); + assert_eq!(completed.cwd.as_deref(), Some("/p/amp-partial")); +} + +// ── opencode ───────────────────────────────────────────────────────────────── + +/// Build a real (valid) opencode.db with the canonical minimal schema + rows, using a +/// writable connection (same idiom as `tests/opencode_sqlite.rs`). +fn write_opencode_db(db_path: &Path, rows: &[(&str, Option<&str>, &str, i64, i64)]) { + let conn = rusqlite::Connection::open(db_path).unwrap(); + conn.execute_batch( + "CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT); + CREATE TABLE session ( + id TEXT PRIMARY KEY, directory TEXT, title TEXT, + time_created INTEGER, time_updated INTEGER, time_archived INTEGER, + project_id TEXT, parent_id TEXT + );", + ) + .unwrap(); + for (id, cwd, title, created, updated) in rows { + match cwd { + Some(cwd) => conn + .execute( + "INSERT INTO session (id, directory, title, time_created, time_updated) VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![id, cwd, title, created, updated], + ) + .unwrap(), + None => conn + .execute( + "INSERT INTO session (id, directory, title, time_created, time_updated) VALUES (?1, NULL, ?2, ?3, ?4)", + rusqlite::params![id, title, created, updated], + ) + .unwrap(), + }; + } + drop(conn); +} + +/// Clause 1+2 (opencode, row level): rows the reference's row-mapping tolerates/skips +/// behave identically — a NULL-directory row is quarantined while its healthy sibling in +/// the SAME database stays listed. +#[tokio::test] +async fn opencode_quarantined_rows_do_not_poison_healthy_rows_in_the_same_db() { + let home = TmpDir::new("oc-rows"); + let data_home = home.path().join("share").join("opencode"); + std::fs::create_dir_all(&data_home).unwrap(); + write_opencode_db( + &data_home.join("opencode.db"), + &[ + ("ses_ok", Some("/repo/ok"), "OpenCode healthy", 1000, 5000), + ("ses_nocwd", None, "OpenCode no-directory row", 2000, 6000), + ], + ); + + let index = mk_index(vec![Arc::new(OpencodeSource::new(data_home.clone()))]); + let snap = index.snapshot().await; + assert_eq!( + keys(&snap), + vec!["opencode:ses_ok".to_string()], + "NULL-directory row quarantined; healthy row listed; no scan failure recorded" + ); + assert!(index.scan_failures().is_empty()); +} + +/// Clause 1 (opencode, db level, COLD): a corrupt database at boot is a recorded scan +/// failure with an empty listing — never a silent healthy "no sessions" snapshot. +#[tokio::test] +async fn opencode_corrupt_db_at_cold_boot_records_a_scan_failure_not_a_healthy_empty() { + let home = TmpDir::new("oc-cold-corrupt"); + let data_home = home.path().join("share").join("opencode"); + std::fs::create_dir_all(&data_home).unwrap(); + std::fs::write( + data_home.join("opencode.db"), + b"not a sqlite database, deliberately corrupted", + ) + .unwrap(); + + let index = mk_index(vec![Arc::new(OpencodeSource::new(data_home.clone()))]); + let snap = index.snapshot().await; + assert!(snap.is_empty(), "a corrupt db lists nothing"); + assert_eq!( + index.scan_failures(), + vec!["opencode".to_string()], + "the outage is RECORDED (degraded/unsearchable), never presented as healthy-empty" + ); +} + +/// Clause 1+3 (opencode, db level, WARM): corrupting the db mid-run (mtime MOVED — the +/// re-query leg, unlike the unchanged-mtime health-check leg already pinned in-crate) +/// preserves the cached sessions AND records the failure; restoring a healthy db clears +/// the failure and re-lists, without a restart. +#[tokio::test] +async fn opencode_corrupt_replace_preserves_sessions_and_healthy_restore_recovers() { + let home = TmpDir::new("oc-warm-corrupt"); + let data_home = home.path().join("share").join("opencode"); + std::fs::create_dir_all(&data_home).unwrap(); + let db = data_home.join("opencode.db"); + write_opencode_db(&db, &[("ses_a", Some("/repo/a"), "Session A", 1000, 5000)]); + + let index = mk_index(vec![Arc::new(OpencodeSource::new(data_home.clone()))]); + let snap0 = index.snapshot().await; + assert_eq!(keys(&snap0), vec!["opencode:ses_a".to_string()]); + assert!(index.scan_failures().is_empty(), "sanity: healthy at boot"); + + // Corrupt the db (content replace moves mtime AND size -> change-token forces a + // re-query, and the re-query errors on the garbage page). + tokio::time::sleep(Duration::from_millis(30)).await; + std::fs::write(&db, b"corrupted to garbage mid-run, no longer sqlite").unwrap(); + + // The failure becomes visible within one settled sweep while the cached session is + // preserved (never a healthy-empty lie, never a dropped corpus). + let mut saw_failure = false; + let start = std::time::Instant::now(); + while start.elapsed() < SETTLE { + let items = index.snapshot().await; + if index.scan_failures() == vec!["opencode".to_string()] { + assert_eq!( + keys(&items), + vec!["opencode:ses_a".to_string()], + "cached opencode sessions preserved through the corruption" + ); + saw_failure = true; + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + assert!(saw_failure, "corrupt db is recorded as a scan failure"); + + // Restore a healthy db (the provider repaired itself): failure clears, sessions + // re-list — without a restart. The garbage file must go first: sqlite cannot `CREATE + // TABLE` over a non-database file. + tokio::time::sleep(Duration::from_millis(30)).await; + std::fs::remove_file(&db).unwrap(); + write_opencode_db(&db, &[("ses_a", Some("/repo/a"), "Session A", 1000, 5000)]); + let mut recovered = false; + let start = std::time::Instant::now(); + while start.elapsed() < SETTLE { + let items = index.snapshot().await; + if index.scan_failures().is_empty() && keys(&items) == vec!["opencode:ses_a".to_string()] { + recovered = true; + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + assert!( + recovered, + "restored healthy db clears the failure and re-lists" + ); +} diff --git a/crates/freshell-terminal/src/registry.rs b/crates/freshell-terminal/src/registry.rs index c11684378..d4148f839 100644 --- a/crates/freshell-terminal/src/registry.rs +++ b/crates/freshell-terminal/src/registry.rs @@ -46,7 +46,6 @@ use std::collections::{HashMap, VecDeque}; use std::io; use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; -use std::time::{SystemTime, UNIX_EPOCH}; use freshell_platform::SpawnSpec; use freshell_protocol::{ @@ -111,11 +110,15 @@ pub fn compute_scrollback_max_bytes(scrollback_lines: i64) -> i64 { } /// `Date.now()` — epoch milliseconds. +/// +/// HARNESS-14: routed through the shared, env-gated test clock +/// (`freshell_platform::clock`). Gate OFF (every normal build/run) the call +/// is an identity passthrough to `SystemTime::now()`, so production behavior +/// is byte-identical; gate ON (a `FRESHELL_TEST_CLOCK=1` test boot) every +/// activity stamp AND the `enforce_idle_kills` threshold math move with the +/// one clock a spec can advance/freeze without wall-clock sleeps. fn now_ms() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) + freshell_platform::clock::now_ms() } /// One attached connection's subscription to a terminal's live stream. diff --git a/crates/freshell-terminal/tests/test_clock_routing.rs b/crates/freshell-terminal/tests/test_clock_routing.rs new file mode 100644 index 000000000..9211deb33 --- /dev/null +++ b/crates/freshell-terminal/tests/test_clock_routing.rs @@ -0,0 +1,84 @@ +//! HARNESS-14 — routing proof for the `freshell-terminal` idle seam, run as +//! an INTEGRATION binary (its own process) on purpose: the shared test clock +//! is process-global, so overriding it in the crate's unit-test binary would +//! pollute parallel sibling tests (proven: an in-module version of this test +//! froze/advanced the clock under the pre-existing TTL test and turned it +//! red). With a separate process, the override is free to be total. +//! +//! Proves: `TerminalRegistry::enforce_idle_kills` follows virtual +//! `advance_ms()` steps only — frozen time never ages a terminal, and two +//! fixtures created at different frozen instants reap in deterministic +//! order. Zero wall-clock sleeps for the virtual waits. + +use std::sync::{Mutex, MutexGuard}; + +use freshell_terminal::registry::{HeadlessTerminal, TerminalRegistry}; + +/// Serialize + scope the process-global override within THIS binary. +static LOCK: Mutex<()> = Mutex::new(()); + +struct GateGuard { + _guard: MutexGuard<'static, ()>, +} + +impl GateGuard { + fn enable() -> Self { + let guard = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + freshell_platform::clock::set_enabled_override_for_tests(Some(true)); + freshell_platform::clock::reset().expect("override enabled"); + Self { _guard: guard } + } +} + +impl Drop for GateGuard { + fn drop(&mut self) { + let _ = freshell_platform::clock::reset(); + freshell_platform::clock::set_enabled_override_for_tests(None); + } +} + +fn headless(reg: &TerminalRegistry, id: &str) { + reg.register_headless(HeadlessTerminal { + terminal_id: id.to_string(), + stream_id: format!("S-{id}"), + mode: "shell".to_string(), + resume_session_id: None, + create_request_id: None, + created_at: None, // stamped from the (routed) clock + }); +} + +#[test] +fn enforce_idle_kills_follows_the_shared_test_clock_when_enabled() { + let _gate = GateGuard::enable(); + let reg = TerminalRegistry::new(); + headless(®, "T-frozen-A"); + reg.set_auto_kill_idle_minutes(15); + + // Frozen clock: real elapsed time is irrelevant — no reap. + freshell_platform::clock::freeze().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(25)); + assert!( + reg.enforce_idle_kills().is_empty(), + "frozen time is idle-0 for a freshly created terminal" + ); + + // Cross the 15-minute threshold in one virtual step: A reaps. + freshell_platform::clock::advance_ms(16 * 60_000).unwrap(); + assert_eq!( + reg.enforce_idle_kills(), + vec!["T-frozen-A".to_string()], + "advancing the shared clock past the threshold must reap" + ); + assert!(reg.inventory().is_empty()); + + // A terminal created at a LATER frozen instant survives a step that + // only carries it to 11 idle minutes (deterministic fixture ordering). + freshell_platform::clock::reset().unwrap(); + freshell_platform::clock::freeze().unwrap(); + headless(®, "T-frozen-B"); + freshell_platform::clock::advance_ms(11 * 60_000).unwrap(); + assert!(reg.enforce_idle_kills().is_empty(), "B is 11min < 15min"); + freshell_platform::clock::advance_ms(5 * 60_000).unwrap(); + assert_eq!(reg.enforce_idle_kills(), vec!["T-frozen-B".to_string()]); +} diff --git a/crates/freshell-ws/Cargo.toml b/crates/freshell-ws/Cargo.toml index f50604ef5..6917c57f8 100644 --- a/crates/freshell-ws/Cargo.toml +++ b/crates/freshell-ws/Cargo.toml @@ -103,8 +103,13 @@ libc = "0.2" tempfile = "3" # DIAG-01 test facility: a capturing `Layer` + `tracing::subscriber::set_default` # to assert lifecycle events fire, without depending on the global subscriber -# `freshell-server` owns. -tracing-subscriber = "0.3" +# `freshell-server` owns. `env-filter` backs the regression test that the +# per-connection `ws_conn` span survives an operator's `RUST_LOG=warn`-style +# level filter (the span is context infrastructure, so it must outlive any +# filter that still admits events). No new downloads: the env-filter dep tree +# (matchers/regex-automata) is already in the workspace lock via +# freshell-server's own tracing-subscriber. +tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Real socket integration tests for the WS keepalive contract (ping/pong cadence, # broadcast-flood survival): a real axum server on an ephemeral loopback port + # a real WS client. `net`/`rt-multi-thread` let the test binary bind a listener diff --git a/crates/freshell-ws/src/codex_association.rs b/crates/freshell-ws/src/codex_association.rs index c3e62c0ae..ced9eac75 100644 --- a/crates/freshell-ws/src/codex_association.rs +++ b/crates/freshell-ws/src/codex_association.rs @@ -289,26 +289,8 @@ mod tests { auth_token: StdArc::clone(&auth_token), server_instance_id: StdArc::new("srv-1111".to_string()), boot_id: StdArc::new("boot-2222".to_string()), - settings: StdArc::new( - serde_json::from_value(serde_json::json!({ - "ai": {}, - "codingCli": { "enabledProviders": [], "mcpServer": true, "providers": {} }, - "editor": { "externalEditor": "auto" }, - "extensions": { "disabled": [] }, - "freshAgent": { "defaultPlugins": [], "enabled": false, "providers": {} }, - "logging": { "debug": false }, - "network": { "configured": true, "host": "127.0.0.1" }, - "panes": { "defaultNewPane": "ask" }, - "safety": { "autoKillIdleMinutes": 15 }, - "sidebar": { - "autoGenerateTitles": true, - "excludeFirstChatMustStart": false, - "excludeFirstChatSubstrings": [] - }, - "terminal": { "scrollback": 10000 } - })) - .unwrap(), - ), + settings: StdArc::new(crate::test_settings()), + handshake_settings: StdArc::new(tokio::sync::RwLock::new(crate::test_settings())), broadcast_tx: StdArc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/src/codex_proxy_route.rs b/crates/freshell-ws/src/codex_proxy_route.rs index cb9cec536..c6ae66e1d 100644 --- a/crates/freshell-ws/src/codex_proxy_route.rs +++ b/crates/freshell-ws/src/codex_proxy_route.rs @@ -232,26 +232,8 @@ mod tests { auth_token: StdArc::clone(&auth_token), server_instance_id: StdArc::new("srv-1111".to_string()), boot_id: StdArc::new("boot-2222".to_string()), - settings: StdArc::new( - serde_json::from_value(serde_json::json!({ - "ai": {}, - "codingCli": { "enabledProviders": [], "mcpServer": true, "providers": {} }, - "editor": { "externalEditor": "auto" }, - "extensions": { "disabled": [] }, - "freshAgent": { "defaultPlugins": [], "enabled": false, "providers": {} }, - "logging": { "debug": false }, - "network": { "configured": true, "host": "127.0.0.1" }, - "panes": { "defaultNewPane": "ask" }, - "safety": { "autoKillIdleMinutes": 15 }, - "sidebar": { - "autoGenerateTitles": true, - "excludeFirstChatMustStart": false, - "excludeFirstChatSubstrings": [] - }, - "terminal": { "scrollback": 10000 } - })) - .unwrap(), - ), + settings: StdArc::new(crate::test_settings()), + handshake_settings: StdArc::new(tokio::sync::RwLock::new(crate::test_settings())), broadcast_tx: StdArc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/src/create_dedupe.rs b/crates/freshell-ws/src/create_dedupe.rs index 5c2bd5f70..34fb8a71f 100644 --- a/crates/freshell-ws/src/create_dedupe.rs +++ b/crates/freshell-ws/src/create_dedupe.rs @@ -1,13 +1,12 @@ //! Server-wide `terminal.create` requestId -> terminal dedupe guard //! (legacy: `server/ws-handler.ts` — server-global `createdByRequestId` -//! settled cache (declaration :467, lookup :2167-2172), per-connection -//! in-flight sentinel (`ClientState`, :1166; set :2434), create-lock -//! serialization (:2159-2161)). The Rust port had no equivalent (fresh -//! UUIDs minted unconditionally, terminal.rs:748; omission self-documented -//! at :805-807), and the frozen client re-sends unanswered creates with -//! the SAME requestId on every reconnect — without this guard every -//! resend spawns a duplicate PTY and orphans the original as a detached -//! background session. +//! settled cache (declaration :575, lookup :921-936), per-connection +//! in-flight sentinel (`ClientState` `createdByRequestId`, :478; set :2495), +//! create-lock serialization (:2218, lock/key defined :1002-1033)). The Rust +//! port had no equivalent: fresh UUIDs were minted unconditionally, and the +//! frozen client re-sends unanswered creates with the SAME requestId on every +//! reconnect — without this guard every resend spawns a duplicate PTY and +//! orphans the original as a detached background session. //! //! Mechanism divergence, same wire outcome: legacy serializes duplicates //! on the create lock and answers them from the settled cache on the NEW @@ -17,16 +16,18 @@ //! the stored `terminal.created` to every waiter; every non-settled exit //! (`clear_if_in_flight`) forwards a fail-loud error instead. A silently //! swallowed duplicate would wedge the reconnected pane in 'creating' -//! (A2, TerminalView.tsx:3995-3999). +//! (the frozen client's reply matching is by requestId, +//! TerminalView.tsx:4216; error match :4702). //! //! Eviction semantics: //! - failed create -> the wrapper calls `clear_if_in_flight` (legacy -//! sentinel cleanup, ws-handler.ts:2460), which also notifies waiters +//! sentinel cleanup, ws-handler.ts:2704), which also notifies waiters //! - settled entries are retained for replay for exactly as long as their //! terminal is running (legacy parity with the Node server's //! delete-at-exit requestId pruning: `createdTerminalByRequestId` is -//! pruned eagerly at terminal exit, ws-handler.ts:580-587, and lazily -//! on registry miss, :914-921). Eviction is lazy -- `settle()` prunes +//! pruned eagerly at terminal exit (onTerminalExitBound :591-593 -> +//! forgetCreatedRequestIdsForTerminal :906-910) and lazily +//! on registry miss, :929-931). Eviction is lazy -- `settle()` prunes //! all dead entries on access and `begin()` displaces per-id via the //! `is_running` probe -- with no background task. Within a terminal's //! running lifetime a duplicate replays the original `terminal.created` @@ -46,6 +47,19 @@ use freshell_terminal::FrameSink; // liveness-bounded settled cache), so boxing would add indirection for no measurable // win — and `DuplicateSettled(ServerMessage)` is the task's specified // interface shape. +/// A connection (other than the origin's) that re-sent an in-flight +/// requestId and is owed a reply when the create settles or exits +/// non-settled. The `conn_id` rides along for DIAG-01 dual-carrier +/// correlation: `settle()` logs a per-waiter `ws.terminal.create.settled` +/// event so the waiter's reply carries the same +/// connection_id/request_id/terminal_id join as every other create-reply +/// path (review round 3 finding: bare-FrameSink waiters left that path +/// correlation-blind). +struct Waiter { + conn_id: u64, + sink: FrameSink, +} + #[allow(clippy::large_enum_variant)] enum Entry { /// A create with this requestId is currently gated/queued/in flight. @@ -55,7 +69,7 @@ enum Entry { origin: FrameSink, /// OTHER connections that re-sent this requestId and are owed a /// reply when the create settles or exits non-settled. - waiters: Vec, + waiters: Vec, /// When this sentinel was installed — the age stamp for the /// `terminal_create_duplicate_in_flight` warn line (council /// observability follow-up, PR #552): a duplicate arriving against @@ -66,21 +80,34 @@ enum Entry { Settled { terminal_id: String, created: ServerMessage, - /// The `restore` flag the SETTLED create carried. A later reuse of - /// this `requestId` only replays when its OWN `restore` flag - /// matches -- a genuine blind resend of the identical frame - /// (legacy `inFlightCreates` parity: same requestId, same - /// `restore`). A mismatched reuse (e.g. a plain create's id later - /// reused by a `restore:true` attempt while that same terminal is - /// still live) is a DIFFERENT request wearing the same id -- it - /// must fall through to its own normal path (which may legitimately - /// reject it, e.g. `RESTORE_UNAVAILABLE` while the lineage is still + /// The CANONICALIZED `restore` flag the SETTLED create carried (see + /// [`canonical_restore`]). A later reuse of this `requestId` only + /// replays when its OWN `restore` canonicalizes equal -- a genuine + /// blind resend of the semantically-identical frame (legacy + /// `inFlightCreates` parity: same requestId, same `restore`). A + /// mismatched reuse (e.g. a plain create's id later reused by a + /// `restore:true` attempt while that same terminal is still live) + /// is a DIFFERENT request wearing the same id -- it must fall + /// through to its own normal path (which may legitimately reject + /// it, e.g. `RESTORE_UNAVAILABLE` while the lineage is still /// running) rather than silently being answered with the original /// terminal. - restore: Option, + restore: bool, }, } +/// The wire protocol marks `restore` optional and the SPA OMITS it when +/// false (`TerminalView.tsx` sends `...(restore ? { restore: true } : {})`), +/// so on the wire `None` and `Some(false)` are the SAME request. Literal +/// `Option` equality would treat an explicit-`restore:false` resend of +/// an omitted-`restore` settled create as a flag mismatch — breaking the +/// replay and letting the resend spawn a duplicate PTY (wrap-review r3). +/// Canonicalize to the only distinction that matters: restore is in effect +/// iff the flag is present AND true. +fn canonical_restore(restore: Option) -> bool { + restore == Some(true) +} + #[allow(clippy::large_enum_variant)] // see `Entry` above pub enum DedupeDecision { /// First sighting (or stale settled entry evicted): proceed to create. @@ -97,8 +124,8 @@ pub enum DedupeDecision { /// The fail-loud frame forwarded to waiters on a non-settled exit — the /// same `{ code, message, requestId }` shape `send_create_error` builds -/// (Task 4 Step 5), so the frozen client's requestId match -/// (TerminalView.tsx:3995-3999) fails the pane loud and its retry ladder +/// (Task 4 Step 5), so the frozen client's requestId error match +/// (TerminalView.tsx:4702) fails the pane loud and its retry ladder /// re-drives with the same requestId (the sentinel is gone by then, so /// the retry proceeds as a fresh create). fn waiter_error(request_id: &str) -> ServerMessage { @@ -129,13 +156,17 @@ impl CreateDedupe { request_id: &str, sink: &FrameSink, origin: &FrameSink, - waiters: &mut Vec, + waiters: &mut Vec, started: &Instant, + conn_id: u64, ) { let already_known = - Arc::ptr_eq(origin, sink) || waiters.iter().any(|w| Arc::ptr_eq(w, sink)); + Arc::ptr_eq(origin, sink) || waiters.iter().any(|w| Arc::ptr_eq(&w.sink, sink)); if !already_known { - waiters.push(Arc::clone(sink)); + waiters.push(Waiter { + conn_id, + sink: Arc::clone(sink), + }); } tracing::warn!( target: "freshell_ws::create_dedupe", @@ -174,6 +205,7 @@ impl CreateDedupe { sink: &FrameSink, restore: Option, is_running: impl Fn(&str) -> bool, + conn_id: u64, ) -> DedupeDecision { // Phase 1: classify under the lock. Everything except the settled // liveness question resolves here in one critical section. @@ -185,7 +217,9 @@ impl CreateDedupe { waiters, started, }) => { - Self::note_duplicate_in_flight(request_id, sink, origin, waiters, started); + Self::note_duplicate_in_flight( + request_id, sink, origin, waiters, started, conn_id, + ); return DedupeDecision::DuplicateInFlight; } Some(Entry::Settled { @@ -220,7 +254,7 @@ impl CreateDedupe { }) => { // Raced: another same-id create won the window while we // probed. Fold in as a duplicate of THAT create. - Self::note_duplicate_in_flight(request_id, sink, origin, waiters, started); + Self::note_duplicate_in_flight(request_id, sink, origin, waiters, started, conn_id); return DedupeDecision::DuplicateInFlight; } Some(Entry::Settled { @@ -242,7 +276,7 @@ impl CreateDedupe { // a sentinel leaves the in-flight window unguarded and // a second duplicate inside it would also Proceed → // duplicate PTY). - if running && settled_restore == restore { + if running && settled_restore == canonical_restore(restore) { Act::Replay(created.clone()) } else { Act::InsertSentinel @@ -253,7 +287,7 @@ impl CreateDedupe { // ago — treat it as live (evicting it on the strength // of a probe against the OLD terminal would clobber the // just-settled create and re-spawn a duplicate). - if *now_restore == restore { + if *now_restore == canonical_restore(restore) { Act::Replay(created.clone()) } else { Act::InsertSentinel @@ -297,6 +331,7 @@ impl CreateDedupe { restore: Option, is_running: impl Fn(&str) -> bool, ) { + let restore = canonical_restore(restore); // Phase 1: install the settled entry, take the waiters, and SNAPSHOT // the prune candidates — every OTHER settled entry's (id, terminal). // The just-settled entry is excluded by construction: it was created @@ -351,8 +386,20 @@ impl CreateDedupe { } } + // DIAG-01 dual-carrier: each cross-connection waiter's reply is its + // own create-reply path, so each gets the settle join event tagged + // with ITS connection id (event fields win over the origin's span + // context in the JsonLayer merge). for w in waiters { - w(created.clone()); + if let ServerMessage::TerminalCreated(created_terminal) = created { + crate::terminal::log_create_settled( + w.conn_id, + &created_terminal.request_id, + &created_terminal.terminal_id, + "duplicate_in_flight_waiter", + ); + } + (w.sink)(created.clone()); } } @@ -378,7 +425,7 @@ impl CreateDedupe { } let err = waiter_error(request_id); for w in waiters { - w(err.clone()); + (w.sink)(err.clone()); } } } @@ -411,12 +458,12 @@ mod tests { fn settle_prunes_entries_for_non_running_terminals() { let d = CreateDedupe::default(); let (s, _f) = recording_sink(); - let _ = d.begin("r1", &s, None, |_| true); + let _ = d.begin("r1", &s, None, |_| true, 9); d.settle("r1", "t1", &created_frame(), None, |_| true); // t1's terminal has since exited: the next successful create's // settle sweeps its entry out (prune-on-access; legacy parity with // ws-handler's eager delete-at-exit). - let _ = d.begin("r2", &s, None, |_| true); + let _ = d.begin("r2", &s, None, |_| true, 9); d.settle("r2", "t2", &created_frame(), None, |tid| tid != "t1"); let map = d.entries.lock().expect("lock"); assert_eq!( @@ -431,10 +478,10 @@ mod tests { fn prune_keeps_running_and_in_flight_entries() { let d = CreateDedupe::default(); let (s, _f) = recording_sink(); - let _ = d.begin("r1", &s, None, |_| true); + let _ = d.begin("r1", &s, None, |_| true, 9); d.settle("r1", "t1", &created_frame(), None, |_| true); - let _ = d.begin("r2", &s, None, |_| true); // still in flight - let _ = d.begin("r3", &s, None, |_| true); + let _ = d.begin("r2", &s, None, |_| true, 9); // still in flight + let _ = d.begin("r3", &s, None, |_| true, 9); d.settle("r3", "t3", &created_frame(), None, |_| true); // prune runs; all running { let map = d.entries.lock().expect("lock"); @@ -446,7 +493,7 @@ mod tests { } // r1 still replays after the prune. assert!(matches!( - d.begin("r1", &s, None, |_| true), + d.begin("r1", &s, None, |_| true, 9), DedupeDecision::DuplicateSettled(_) )); } @@ -456,11 +503,11 @@ mod tests { let d = CreateDedupe::default(); let (s1, _f1) = recording_sink(); assert!(matches!( - d.begin("r1", &s1, None, |_| true), + d.begin("r1", &s1, None, |_| true, 9), DedupeDecision::Proceed )); assert!(matches!( - d.begin("r1", &s1, None, |_| true), + d.begin("r1", &s1, None, |_| true, 9), DedupeDecision::DuplicateInFlight )); } @@ -469,10 +516,10 @@ mod tests { fn settled_entry_replays_frame_while_live() { let d = CreateDedupe::default(); let (s1, _f1) = recording_sink(); - let _ = d.begin("r1", &s1, None, |_| true); + let _ = d.begin("r1", &s1, None, |_| true, 9); d.settle("r1", "t1", &created_frame(), None, |_| true); assert!(matches!( - d.begin("r1", &s1, None, |_| true), + d.begin("r1", &s1, None, |_| true, 9), DedupeDecision::DuplicateSettled(_) )); } @@ -481,10 +528,10 @@ mod tests { fn dead_terminal_evicts_settled_entry() { let d = CreateDedupe::default(); let (s1, _f1) = recording_sink(); - let _ = d.begin("r1", &s1, None, |_| true); + let _ = d.begin("r1", &s1, None, |_| true, 9); d.settle("r1", "t1", &created_frame(), None, |_| true); assert!(matches!( - d.begin("r1", &s1, None, |_| false), + d.begin("r1", &s1, None, |_| false, 9), DedupeDecision::Proceed )); } @@ -493,16 +540,16 @@ mod tests { fn clear_if_in_flight_removes_sentinel_but_not_settled() { let d = CreateDedupe::default(); let (s1, _f1) = recording_sink(); - let _ = d.begin("r1", &s1, None, |_| true); + let _ = d.begin("r1", &s1, None, |_| true, 9); d.clear_if_in_flight("r1"); assert!(matches!( - d.begin("r1", &s1, None, |_| true), + d.begin("r1", &s1, None, |_| true, 9), DedupeDecision::Proceed )); d.settle("r1", "t1", &created_frame(), None, |_| true); d.clear_if_in_flight("r1"); assert!(matches!( - d.begin("r1", &s1, None, |_| true), + d.begin("r1", &s1, None, |_| true, 9), DedupeDecision::DuplicateSettled(_) )); } @@ -512,9 +559,9 @@ mod tests { let d = CreateDedupe::default(); let (origin, origin_frames) = recording_sink(); let (other, other_frames) = recording_sink(); - let _ = d.begin("r1", &origin, None, |_| true); + let _ = d.begin("r1", &origin, None, |_| true, 9); assert!(matches!( - d.begin("r1", &other, None, |_| true), + d.begin("r1", &other, None, |_| true, 9), DedupeDecision::DuplicateInFlight )); d.settle("r1", "t1", &created_frame(), None, |_| true); @@ -533,8 +580,8 @@ mod tests { fn same_connection_duplicate_is_not_a_waiter() { let d = CreateDedupe::default(); let (origin, origin_frames) = recording_sink(); - let _ = d.begin("r1", &origin, None, |_| true); - let _ = d.begin("r1", &origin, None, |_| true); + let _ = d.begin("r1", &origin, None, |_| true, 9); + let _ = d.begin("r1", &origin, None, |_| true, 9); d.settle("r1", "t1", &created_frame(), None, |_| true); assert!( origin_frames.lock().expect("frames").is_empty(), @@ -555,19 +602,19 @@ mod tests { let d = CreateDedupe::default(); let (s1, _f1) = recording_sink(); // Settle R as a plain (restore=false) create; terminal stays live. - let _ = d.begin("r1", &s1, Some(false), |_| true); + let _ = d.begin("r1", &s1, Some(false), |_| true, 9); d.settle("r1", "t1", &created_frame(), Some(false), |_| true); // Same id, DIFFERENT flag: proceeds to its own create path... assert!(matches!( - d.begin("r1", &s1, Some(true), |_| true), + d.begin("r1", &s1, Some(true), |_| true, 9), DedupeDecision::Proceed )); // ...but MUST have registered an InFlight sentinel: a second // duplicate while the first is unsettled must NOT also proceed. assert!( matches!( - d.begin("r1", &s1, Some(true), |_| true), + d.begin("r1", &s1, Some(true), |_| true, 9), DedupeDecision::DuplicateInFlight ), "second same-requestId duplicate during the in-flight window must \ @@ -575,6 +622,52 @@ mod tests { ); } + /// Wrap-review r3: the wire makes `restore` OPTIONAL and the SPA omits + /// it when false, so `None` and `Some(false)` are the SAME request. + /// Literal `Option` equality broke the replay for whichever client + /// spelled the flag differently (settle omitted / resend explicit, or + /// vice versa) — InsertSentinel + Proceed would let the resend spawn a + /// duplicate PTY. Both spellings must replay both spellings, while + /// `restore:true` still mismatches (the latch-flip arm above). + #[test] + fn omitted_restore_and_explicit_false_replay_each_other() { + // Settle as omitted (the SPA shape); resend with explicit false. + let d = CreateDedupe::default(); + let (s1, _f1) = recording_sink(); + let _ = d.begin("r1", &s1, None, |_| true, 9); + d.settle("r1", "t1", &created_frame(), None, |_| true); + assert!( + matches!( + d.begin("r1", &s1, Some(false), |_| true, 9), + DedupeDecision::DuplicateSettled(_) + ), + "explicit restore:false must replay an omitted-restore settled create" + ); + + // And the mirror: settled explicit-false, resend omitted. + let d = CreateDedupe::default(); + let (s2, _f2) = recording_sink(); + let _ = d.begin("r2", &s2, Some(false), |_| true, 9); + d.settle("r2", "t2", &created_frame(), Some(false), |_| true); + assert!( + matches!( + d.begin("r2", &s2, None, |_| true, 9), + DedupeDecision::DuplicateSettled(_) + ), + "omitted restore must replay an explicit-false settled create" + ); + + // restore:true still mismatches against both false spellings. + let d = CreateDedupe::default(); + let (s3, _f3) = recording_sink(); + let _ = d.begin("r3", &s3, None, |_| true, 9); + d.settle("r3", "t3", &created_frame(), None, |_| true); + assert!(matches!( + d.begin("r3", &s3, Some(true), |_| true, 9), + DedupeDecision::Proceed + )); + } + /// The sentinel installed by the flag-mismatch arm must behave exactly /// like any other InFlight entry: clear_if_in_flight drops it (waiters /// get the fail-loud error; a retry proceeds fresh) and settle replaces @@ -585,10 +678,10 @@ mod tests { let d = CreateDedupe::default(); let (origin, _f1) = recording_sink(); let (other, other_frames) = recording_sink(); - let _ = d.begin("r1", &origin, Some(false), |_| true); + let _ = d.begin("r1", &origin, Some(false), |_| true, 9); d.settle("r1", "t1", &created_frame(), Some(false), |_| true); - let _ = d.begin("r1", &origin, Some(true), |_| true); // replaces Settled with InFlight - let _ = d.begin("r1", &other, Some(true), |_| true); // cross-conn waiter + let _ = d.begin("r1", &origin, Some(true), |_| true, 9); // replaces Settled with InFlight + let _ = d.begin("r1", &other, Some(true), |_| true, 9); // cross-conn waiter d.clear_if_in_flight("r1"); { let frames = other_frames.lock().expect("frames"); @@ -599,7 +692,7 @@ mod tests { )); } assert!(matches!( - d.begin("r1", &other, Some(true), |_| true), + d.begin("r1", &other, Some(true), |_| true, 9), DedupeDecision::Proceed )); @@ -608,10 +701,10 @@ mod tests { let d = CreateDedupe::default(); let (origin, _f2) = recording_sink(); let (other, other_frames) = recording_sink(); - let _ = d.begin("r2", &origin, Some(false), |_| true); + let _ = d.begin("r2", &origin, Some(false), |_| true, 9); d.settle("r2", "t1", &created_frame(), Some(false), |_| true); - let _ = d.begin("r2", &origin, Some(true), |_| true); // replaces Settled with InFlight - let _ = d.begin("r2", &other, Some(true), |_| true); // waiter + let _ = d.begin("r2", &origin, Some(true), |_| true, 9); // replaces Settled with InFlight + let _ = d.begin("r2", &other, Some(true), |_| true, 9); // waiter d.settle("r2", "t2", &created_frame(), Some(true), |_| true); assert_eq!( other_frames.lock().expect("frames").len(), @@ -620,7 +713,7 @@ mod tests { ); // Replay now keys on the NEW restore flag. assert!(matches!( - d.begin("r2", &other, Some(true), |_| true), + d.begin("r2", &other, Some(true), |_| true, 9), DedupeDecision::DuplicateSettled(_) )); } @@ -639,22 +732,28 @@ mod tests { let d = Arc::new(CreateDedupe::default()); let (s, _f) = recording_sink(); // A settled entry so begin() must consult the probe at all. - let _ = d.begin("r1", &s, None, |_| true); + let _ = d.begin("r1", &s, None, |_| true, 9); d.settle("r1", "t1", &created_frame(), None, |_| true); // An unrelated in-flight sentinel the probe will clear. - let _ = d.begin("r-other", &s, None, |_| true); + let _ = d.begin("r-other", &s, None, |_| true, 9); let d2 = Arc::clone(&d); - let decision = d.begin("r1", &s, None, move |_| { - // Re-entrant dedupe call from inside the probe: only possible - // if the dedupe lock is NOT held around the probe. - d2.clear_if_in_flight("r-other"); - true - }); + let decision = d.begin( + "r1", + &s, + None, + move |_| { + // Re-entrant dedupe call from inside the probe: only possible + // if the dedupe lock is NOT held around the probe. + d2.clear_if_in_flight("r-other"); + true + }, + 9, + ); assert!(matches!(decision, DedupeDecision::DuplicateSettled(_))); // The re-entrant clear really happened. assert!(matches!( - d.begin("r-other", &s, None, |_| true), + d.begin("r-other", &s, None, |_| true, 9), DedupeDecision::Proceed )); } @@ -669,18 +768,24 @@ mod tests { fn entry_resettled_during_probe_replays_fresh_frame_not_stale_eviction() { let d = Arc::new(CreateDedupe::default()); let (s, _f) = recording_sink(); - let _ = d.begin("r1", &s, None, |_| true); + let _ = d.begin("r1", &s, None, |_| true, 9); d.settle("r1", "t1", &created_frame(), None, |_| true); let d2 = Arc::clone(&d); let s2 = Arc::clone(&s); // Probe says t1 is DEAD, and meanwhile (simulated concurrent // create) the id is re-settled onto live t2 with a matching flag. - let decision = d.begin("r1", &s, None, move |_| { - d2.settle("r1", "t2", &created_frame(), None, |_| true); - let _ = &s2; - false // stale snapshot's terminal (t1) is dead - }); + let decision = d.begin( + "r1", + &s, + None, + move |_| { + d2.settle("r1", "t2", &created_frame(), None, |_| true); + let _ = &s2; + false // stale snapshot's terminal (t1) is dead + }, + 9, + ); assert!( matches!(decision, DedupeDecision::DuplicateSettled(_)), "the freshly re-settled entry must be replayed; acting on the \ @@ -688,7 +793,7 @@ mod tests { ); // The fresh entry survives. assert!(matches!( - d.begin("r1", &s, None, |_| true), + d.begin("r1", &s, None, |_| true, 9), DedupeDecision::DuplicateSettled(_) )); } @@ -698,8 +803,8 @@ mod tests { let d = CreateDedupe::default(); let (origin, _f1) = recording_sink(); let (other, other_frames) = recording_sink(); - let _ = d.begin("r1", &origin, None, |_| true); - let _ = d.begin("r1", &other, None, |_| true); + let _ = d.begin("r1", &origin, None, |_| true, 9); + let _ = d.begin("r1", &other, None, |_| true, 9); d.clear_if_in_flight("r1"); { let frames = other_frames.lock().expect("frames"); @@ -711,8 +816,108 @@ mod tests { } // Sentinel is gone: the client's retry proceeds fresh. assert!(matches!( - d.begin("r1", &other, None, |_| true), + d.begin("r1", &other, None, |_| true, 9), DedupeDecision::Proceed )); } + + /// DIAG-01 (review round 3): a cross-connection in-flight duplicate is + /// answered by `settle()` forwarding `terminal.created` over the waiter's + /// previously-bare FrameSink -- WITHOUT the fix, no + /// `ws.terminal.create.settled` join event existed for that waiter's + /// connection at all (the sink knew no conn_id), breaking the ownership + /// contract on the waiter path. + #[test] + fn settle_logs_a_waiter_join_event_with_the_waiters_connection_id() { + use std::collections::BTreeMap; + use tracing::field::{Field, Visit}; + use tracing::{Event, Subscriber}; + use tracing_subscriber::layer::{Context, SubscriberExt}; + use tracing_subscriber::Layer; + + #[derive(Default)] + struct V { + message: String, + fields: BTreeMap, + } + impl Visit for V { + fn record_debug(&mut self, f: &Field, v: &dyn std::fmt::Debug) { + if f.name() == "message" { + self.message = format!("{v:?}"); + } else { + self.fields.insert(f.name().to_string(), format!("{v:?}")); + } + } + fn record_str(&mut self, f: &Field, v: &str) { + if f.name() == "message" { + self.message = v.to_string(); + } else { + self.fields.insert(f.name().to_string(), v.to_string()); + } + } + fn record_u64(&mut self, f: &Field, v: u64) { + self.fields.insert(f.name().to_string(), v.to_string()); + } + } + type CapturedEvent = (String, BTreeMap); + struct L(Arc>>); + impl Layer for L { + fn on_event(&self, e: &Event<'_>, _ctx: Context<'_, S>) { + let mut v = V::default(); + e.record(&mut v); + self.0.lock().unwrap().push((v.message, v.fields)); + } + } + + let events = Arc::new(Mutex::new(Vec::new())); + let subscriber = tracing_subscriber::registry().with(L(Arc::clone(&events))); + let _guard = tracing::subscriber::set_default(subscriber); + + let d = CreateDedupe::default(); + let (origin, _origin_frames) = recording_sink(); + let (waiter, waiter_frames) = recording_sink(); + let _ = d.begin("rX", &origin, None, |_| true, 1); + let _ = d.begin("rX", &waiter, None, |_| true, 2); // second connection, in flight + d.settle( + "rX", + "tX", + &terminal_created_frame("rX", "tX"), + None, + |_| true, + ); + + assert_eq!( + waiter_frames.lock().expect("frames lock").len(), + 1, + "waiter control: the reply frame itself must still be forwarded" + ); + let captured = events.lock().expect("capture lock").clone(); + let join = captured + .iter() + .find(|(msg, fields)| { + msg == "ws.terminal.create.settled" + && fields.get("terminal_id").map(String::as_str) == Some("tX") + && fields.get("path").map(String::as_str) == Some("duplicate_in_flight_waiter") + }) + .expect("settle must log a ws.terminal.create.settled join for the waiter"); + assert_eq!( + join.1.get("connection_id").map(String::as_str), + Some("2"), + "the join event must name the WAITER's connection id, not the origin's" + ); + assert_eq!(join.1.get("request_id").map(String::as_str), Some("rX")); + } + + fn terminal_created_frame(request_id: &str, terminal_id: &str) -> ServerMessage { + ServerMessage::TerminalCreated(freshell_protocol::server_messages::TerminalCreated { + created_at: 0, + request_id: request_id.to_string(), + terminal_id: terminal_id.to_string(), + clear_codex_durability: None, + cwd: None, + notice: None, + restore_error: None, + session_ref: None, + }) + } } diff --git a/crates/freshell-ws/src/create_gate.rs b/crates/freshell-ws/src/create_gate.rs index fae2f34aa..1b4e5910e 100644 --- a/crates/freshell-ws/src/create_gate.rs +++ b/crates/freshell-ws/src/create_gate.rs @@ -4,6 +4,7 @@ use freshell_protocol::ServerMessage; use freshell_terminal::FrameSink; +use tracing::Instrument; /// Where a `terminal.create` reply goes. pub(crate) enum CreateOutput<'a> { @@ -70,193 +71,201 @@ pub(crate) fn spawn_gated_restore_create( ) { let state = state.clone(); let sink = std::sync::Arc::clone(conn_sink); - tokio::spawn(async move { - // P1 (graceful restore/resume S1): prepare — resume-identity - // derivation + the codex managed plan — runs BEFORE the gate, so - // permits only ever cover fast, mode-uniform PTY-spawn->settle work - // and codex planning can no longer starve other modes' restores. - // The restore-class plan wait is cancel-aware with no wall-clock - // death (LaunchClass::Restore; overflow -> RATE_LIMITED). - let prepared = match crate::terminal::prepare_launch(&create, &state, &mut cancel_rx).await - { - Ok(prepared) => prepared, - Err(crate::terminal::PrepareError::Cancelled) => { + // DIAG-01: this fn is called from within the connection loop's `ws_conn` + // span context; carry it into the detached task so the restore create's + // events (prepare/gate/spawn) keep the serving connection's `connection_id`. + tokio::spawn( + async move { + // P1 (graceful restore/resume S1): prepare — resume-identity + // derivation + the codex managed plan — runs BEFORE the gate, so + // permits only ever cover fast, mode-uniform PTY-spawn->settle work + // and codex planning can no longer starve other modes' restores. + // The restore-class plan wait is cancel-aware with no wall-clock + // death (LaunchClass::Restore; overflow -> RATE_LIMITED). + let prepared = + match crate::terminal::prepare_launch(&create, &state, &mut cancel_rx).await { + Ok(prepared) => prepared, + Err(crate::terminal::PrepareError::Cancelled) => { + tracing::info!( + target: "freshell_ws::spawn_gate", + request_id = %create.request_id, + "restore_create_cancelled" + ); + // Non-settled exit: drop the dedupe sentinel (and fail any + // cross-connection waiters loud) so a resend proceeds fresh. + state.create_dedupe.clear_if_in_flight(&create.request_id); + return; + } + Err(crate::terminal::PrepareError::PlanQueueFull) => { + let mut out = CreateOutput::Channel(&sink); + let _ = crate::terminal::send_create_error( + &mut out, + ErrorCode::RateLimited, + "Too many concurrent codex launches".to_string(), + &create.request_id, + ) + .await; + state.create_dedupe.clear_if_in_flight(&create.request_id); + return; + } + // (No Reject arm: post-A12, prepare_launch cannot reject — the + // claude RESTORE_UNAVAILABLE ladder runs inside handle_create, + // after the adopt/D8 arms, exactly as today.) + Err(crate::terminal::PrepareError::PlanFailed(message)) => { + // Same frame this failure produced when it happened inside + // handle_create (`error{code:PTY_SPAWN_FAILED}`). + let mut out = CreateOutput::Channel(&sink); + let _ = crate::terminal::send_create_error( + &mut out, + ErrorCode::PtySpawnFailed, + message, + &create.request_id, + ) + .await; + state.create_dedupe.clear_if_in_flight(&create.request_id); + return; + } + }; + // Restore-class gate wait: cancel-aware, NO timeout (D-GATE-SOFT + // generalized: contention may not kill a restore). QueueFull still + // fails loud (-> RATE_LIMITED via spawn_gate_error_parts); Timeout + // is unreachable on this path. Interactive creates never ride this + // fn and keep spawn_timeout_ms. + let permit = match state.spawn_gate.acquire_unbounded(&mut cancel_rx).await { + Ok(permit) => permit, + Err(SpawnGateError::Cancelled) => { + tracing::info!( + target: "freshell_ws::spawn_gate", + request_id = %create.request_id, + "restore_create_cancelled" + ); + // `prepared` drops here: the RAII guard discards the sidecar. + state.create_dedupe.clear_if_in_flight(&create.request_id); + return; + } + Err(err) => { + // A prepared codex launch IS materialized now (P1 inverted + // the old "nothing has been materialized yet" invariant); + // dropping `prepared` on this return discards it via the + // PreparedCodexLaunch guard. QueueFull maps to RATE_LIMITED + // (spawn_gate_error_parts) — the ladder absorbs it. + let (code, msg) = spawn_gate_error_parts(err); + let mut out = CreateOutput::Channel(&sink); + let _ = crate::terminal::send_create_error( + &mut out, + code, + msg.to_string(), + &create.request_id, + ) + .await; + state.create_dedupe.clear_if_in_flight(&create.request_id); + return; + } + }; + // Last-instant check: the permit may have been granted a beat after + // the client vanished. Nothing has been spawned yet — abandon + // (dropping `prepared` discards the sidecar). + if *cancel_rx.borrow() { tracing::info!( target: "freshell_ws::spawn_gate", request_id = %create.request_id, "restore_create_cancelled" ); - // Non-settled exit: drop the dedupe sentinel (and fail any - // cross-connection waiters loud) so a resend proceeds fresh. state.create_dedupe.clear_if_in_flight(&create.request_id); return; } - Err(crate::terminal::PrepareError::PlanQueueFull) => { - let mut out = CreateOutput::Channel(&sink); - let _ = crate::terminal::send_create_error( - &mut out, - ErrorCode::RateLimited, - "Too many concurrent codex launches".to_string(), - &create.request_id, - ) - .await; - state.create_dedupe.clear_if_in_flight(&create.request_id); - return; - } - // (No Reject arm: post-A12, prepare_launch cannot reject — the - // claude RESTORE_UNAVAILABLE ladder runs inside handle_create, - // after the adopt/D8 arms, exactly as today.) - Err(crate::terminal::PrepareError::PlanFailed(message)) => { - // Same frame this failure produced when it happened inside - // handle_create (`error{code:PTY_SPAWN_FAILED}`). - let mut out = CreateOutput::Channel(&sink); - let _ = crate::terminal::send_create_error( - &mut out, - ErrorCode::PtySpawnFailed, - message, - &create.request_id, - ) - .await; - state.create_dedupe.clear_if_in_flight(&create.request_id); - return; - } - }; - // Restore-class gate wait: cancel-aware, NO timeout (D-GATE-SOFT - // generalized: contention may not kill a restore). QueueFull still - // fails loud (-> RATE_LIMITED via spawn_gate_error_parts); Timeout - // is unreachable on this path. Interactive creates never ride this - // fn and keep spawn_timeout_ms. - let permit = match state.spawn_gate.acquire_unbounded(&mut cancel_rx).await { - Ok(permit) => permit, - Err(SpawnGateError::Cancelled) => { + // A10 shutdown-race pre-check (V3): kill_all snapshots ids once + // (registry.rs:889-892); if shutdown already began, nothing has been + // spawned yet — abandon instead of inserting a PTY the snapshot will + // never visit. (`prepared` drops -> sidecar discarded.) + if state + .shutdown_started + .load(std::sync::atomic::Ordering::SeqCst) + { tracing::info!( target: "freshell_ws::spawn_gate", request_id = %create.request_id, - "restore_create_cancelled" + "restore_create_abandoned_for_shutdown" ); - // `prepared` drops here: the RAII guard discards the sidecar. state.create_dedupe.clear_if_in_flight(&create.request_id); return; } - Err(err) => { - // A prepared codex launch IS materialized now (P1 inverted - // the old "nothing has been materialized yet" invariant); - // dropping `prepared` on this return discards it via the - // PreparedCodexLaunch guard. QueueFull maps to RATE_LIMITED - // (spawn_gate_error_parts) — the ladder absorbs it. - let (code, msg) = spawn_gate_error_parts(err); + // Permit held across PTY spawn -> registry insert -> meta/identity -> + // terminal.created -> broadcasts (the spawn-to-settled requirement, + // pinned by permit_released_only_after_work_completes). Codex + // planning happens ABOVE, outside the permit — the hold is now fast + // and mode-uniform. Replies go through the non-blocking conn sink, + // so no stalled client can wedge the permit (the da5d9b5c hazard + // still cannot exist on this path). + let request_id = create.request_id.clone(); + // A5 residual signal (V3), hold side: the permit-held awaits below + // are deadline-free (PTY spawn terminal.rs:2253-2269, association + // fs walk :2431-2454, fsync ledger writes :2517-2545) — a wedged + // hold would otherwise be invisible. Warn ONCE at ~30s while the + // hold is still in flight; abort the watchdog when the hold + // settles. Logging only — no frames, no protocol change. + let hold_watchdog = tokio::spawn({ + let request_id = request_id.clone(); + async move { + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + tracing::warn!( + target: "freshell_ws::spawn_gate", + request_id = %request_id, + "spawn_gate_permit_hold_slow" + ); + } + // DIAG-01: keep the connection context on the watchdog event too. + .instrument(tracing::Span::current()) + }); + hold_permit_across(permit, async { let mut out = CreateOutput::Channel(&sink); - let _ = crate::terminal::send_create_error( + // Fresh limiter, never consulted: `handle_create`'s rate-limit + // check is gated on `create.restore != Some(true)`, and this + // path is restore:true by construction (the `if create.restore + // == Some(true)` branch in `handle_client_text`) — so this is a + // throwaway to satisfy the shared signature, not a live budget. + let mut create_limiter = crate::create_limit::CreateRateLimiter::new( + state.create_protect.rate_limit, + state.create_protect.rate_window_ms, + ); + let _ = crate::terminal::handle_create( + create, + Some(prepared), &mut out, - code, - msg.to_string(), - &create.request_id, + &state, + conn_id, + pane_reconcile_v1, + &mut create_limiter, ) .await; - state.create_dedupe.clear_if_in_flight(&create.request_id); - return; - } - }; - // Last-instant check: the permit may have been granted a beat after - // the client vanished. Nothing has been spawned yet — abandon - // (dropping `prepared` discards the sidecar). - if *cancel_rx.borrow() { - tracing::info!( - target: "freshell_ws::spawn_gate", - request_id = %create.request_id, - "restore_create_cancelled" - ); - state.create_dedupe.clear_if_in_flight(&create.request_id); - return; - } - // A10 shutdown-race pre-check (V3): kill_all snapshots ids once - // (registry.rs:889-892); if shutdown already began, nothing has been - // spawned yet — abandon instead of inserting a PTY the snapshot will - // never visit. (`prepared` drops -> sidecar discarded.) - if state - .shutdown_started - .load(std::sync::atomic::Ordering::SeqCst) - { - tracing::info!( - target: "freshell_ws::spawn_gate", - request_id = %create.request_id, - "restore_create_abandoned_for_shutdown" - ); - state.create_dedupe.clear_if_in_flight(&create.request_id); - return; - } - // Permit held across PTY spawn -> registry insert -> meta/identity -> - // terminal.created -> broadcasts (the spawn-to-settled requirement, - // pinned by permit_released_only_after_work_completes). Codex - // planning happens ABOVE, outside the permit — the hold is now fast - // and mode-uniform. Replies go through the non-blocking conn sink, - // so no stalled client can wedge the permit (the da5d9b5c hazard - // still cannot exist on this path). - let request_id = create.request_id.clone(); - // A5 residual signal (V3), hold side: the permit-held awaits below - // are deadline-free (PTY spawn terminal.rs:2253-2269, association - // fs walk :2431-2454, fsync ledger writes :2517-2545) — a wedged - // hold would otherwise be invisible. Warn ONCE at ~30s while the - // hold is still in flight; abort the watchdog when the hold - // settles. Logging only — no frames, no protocol change. - let hold_watchdog = tokio::spawn({ - let request_id = request_id.clone(); - async move { - tokio::time::sleep(std::time::Duration::from_secs(30)).await; - tracing::warn!( - target: "freshell_ws::spawn_gate", - request_id = %request_id, - "spawn_gate_permit_hold_slow" - ); - } - }); - hold_permit_across(permit, async { - let mut out = CreateOutput::Channel(&sink); - // Fresh limiter, never consulted: `handle_create`'s rate-limit - // check is gated on `create.restore != Some(true)`, and this - // path is restore:true by construction (the `if create.restore - // == Some(true)` branch in `handle_client_text`) — so this is a - // throwaway to satisfy the shared signature, not a live budget. - let mut create_limiter = crate::create_limit::CreateRateLimiter::new( - state.create_protect.rate_limit, - state.create_protect.rate_window_ms, - ); - let _ = crate::terminal::handle_create( - create, - Some(prepared), - &mut out, - &state, - conn_id, - pane_reconcile_v1, - &mut create_limiter, - ) + // Covers create failure: no-op when handle_create settled the entry, + // drops the InFlight sentinel (failing waiters loud) when it did not. + state.create_dedupe.clear_if_in_flight(&request_id); + // A10 shutdown-race post-check (V3): shutdown may have begun DURING + // the create, after main's kill_all snapshot. The server is reaping + // everything anyway, so an idempotent kill_all here reaps our own + // just-inserted terminal (and any other late insert). Belt to the + // pre-check's braces; main.rs adds a drain re-sweep too (Task 7 + // Step 2b). + if state + .shutdown_started + .load(std::sync::atomic::Ordering::SeqCst) + { + let killed = state.registry.kill_all(); + tracing::info!( + target: "freshell_ws::spawn_gate", + request_id = %request_id, + killed, + "restore_create_settled_during_shutdown_reaped" + ); + } + }) .await; - // Covers create failure: no-op when handle_create settled the entry, - // drops the InFlight sentinel (failing waiters loud) when it did not. - state.create_dedupe.clear_if_in_flight(&request_id); - // A10 shutdown-race post-check (V3): shutdown may have begun DURING - // the create, after main's kill_all snapshot. The server is reaping - // everything anyway, so an idempotent kill_all here reaps our own - // just-inserted terminal (and any other late insert). Belt to the - // pre-check's braces; main.rs adds a drain re-sweep too (Task 7 - // Step 2b). - if state - .shutdown_started - .load(std::sync::atomic::Ordering::SeqCst) - { - let killed = state.registry.kill_all(); - tracing::info!( - target: "freshell_ws::spawn_gate", - request_id = %request_id, - killed, - "restore_create_settled_during_shutdown_reaped" - ); - } - }) - .await; - // Hold settled (fast path): silence the slow-hold watchdog. - hold_watchdog.abort(); - }); + // Hold settled (fast path): silence the slow-hold watchdog. + hold_watchdog.abort(); + } + .instrument(tracing::Span::current()), + ); } #[cfg(test)] diff --git a/crates/freshell-ws/src/create_limit.rs b/crates/freshell-ws/src/create_limit.rs index 446ef4d44..1745bcf8e 100644 --- a/crates/freshell-ws/src/create_limit.rs +++ b/crates/freshell-ws/src/create_limit.rs @@ -107,11 +107,13 @@ impl CreateRateLimiter { } /// Wall-clock epoch milliseconds for limiter stamping. +/// +/// HARNESS-14: routed through the shared, env-gated test clock +/// (`freshell_platform::clock`; gate-off identity passthrough), so a +/// `FRESHELL_TEST_CLOCK=1` test boot can advance past the create-rate +/// window without wall-clock sleeps. pub fn epoch_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) + freshell_platform::clock::now_ms().max(0) as u64 } #[cfg(test)] diff --git a/crates/freshell-ws/src/lib.rs b/crates/freshell-ws/src/lib.rs index 5358be38d..52b296d2a 100644 --- a/crates/freshell-ws/src/lib.rs +++ b/crates/freshell-ws/src/lib.rs @@ -100,8 +100,27 @@ pub struct WsState { pub server_instance_id: Arc, /// `boot-` — stable for the life of this server process. pub boot_id: Arc, - /// The default server settings tree emitted in `settings.updated`. + /// The default server settings tree. Boot-frozen snapshot, consumed ONLY + /// by `terminal.rs`'s create-time derivations (`cli_provider_settings`, + /// the codex launch plan, `resolve_create_cwd`'s `defaultCwd` fallback). + /// CFG-06 owns making those NEW-OPERATION consumers resolve live values; + /// do NOT repoint this field at [`WsState::handshake_settings`] — the + /// per-consumer proof obligations are CFG-06's, and the boundary is + /// pinned by `handshake_settings_updated_reflects_live_writes_between_ + /// connections`. pub settings: Arc, + /// CFG-12: the LIVE server-settings tree, resolved on EVERY `/ws` + /// connection for the handshake's `settings.updated` frame (legacy + /// parity: the original's `handshakeSnapshotProvider` awaits + /// `configStore.getSettings()` per connection (`server/index.ts:415-427`, + /// sent via `ws-handler.ts:1815-1845`). Freshell-server wires + /// `SettingsStore::shared_settings_lock()` in here, so a value committed + /// by `PATCH /api/settings` is exactly what the next (re)connecting + /// client's handshake carries — with the client's last-write-wins + /// application of that frame, a boot-frozen copy here would erase the + /// fresh value `/api/bootstrap` already delivered. Read-only from this + /// crate's perspective: the owning `SettingsStore` is the only writer. + pub handshake_settings: Arc>, /// GAP1 (CFG-03 checklist follow-up): the boot-time `config.fallback` /// notice, if the primary configuration needed to fall back (corrupt /// primary -> backup restore or defaults) at boot -- `None` for a @@ -408,6 +427,34 @@ pub fn now_iso() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) } +/// The shared minimal-but-structurally-valid `ServerSettings` fixture every +/// in-crate unit test seeds `WsState` from (the exact default tree is pinned +/// by freshell-server's fixture test; here we only need SOMETHING to emit). +/// Crate-visible so `terminal.rs` / `*_association.rs` / `codex_proxy_route.rs` +/// test modules build from ONE literal instead of five byte-identical copies +/// (hoisted when CFG-12 gave `WsState` a second settings-carrying field). +#[cfg(test)] +pub(crate) fn test_settings() -> ServerSettings { + serde_json::from_value(serde_json::json!({ + "ai": {}, + "codingCli": { "enabledProviders": [], "mcpServer": true, "providers": {} }, + "editor": { "externalEditor": "auto" }, + "extensions": { "disabled": [] }, + "freshAgent": { "defaultPlugins": [], "enabled": false, "providers": {} }, + "logging": { "debug": false }, + "network": { "configured": true, "host": "127.0.0.1" }, + "panes": { "defaultNewPane": "ask" }, + "safety": { "autoKillIdleMinutes": 15 }, + "sidebar": { + "autoGenerateTitles": true, + "excludeFirstChatMustStart": false, + "excludeFirstChatSubstrings": [] + }, + "terminal": { "scrollback": 10000 } + })) + .unwrap() +} + /// Run `f` on a repeating `interval` cadence, forever, on a spawned tokio task. /// The generic scheduling primitive behind `spawn_idle_monitor` -- split out so /// the ticker cadence itself (the actual new logic: a `tokio::time::interval` @@ -456,8 +503,8 @@ pub fn spawn_idle_monitor( /// treating its persisted terminals as dead (`clearDeadTerminals` → recreate, which /// would lose scrollback). On a truly fresh boot the registry is empty, so this stays /// byte-identical to the clean-boot handshake the oracle's T0/determinism tiers pin. -pub fn build_handshake(state: &WsState) -> Vec { - build_handshake_with_capabilities(state, false, false) +pub async fn build_handshake(state: &WsState) -> Vec { + build_handshake_with_capabilities(state, false, false).await } /// [`build_handshake`], parameterized on the connection's negotiated @@ -465,7 +512,14 @@ pub fn build_handshake(state: &WsState) -> Vec { /// `ready.capabilities` advertisement is emitted **only when the client's /// `hello` opted in** — today's frozen client doesn't, so the emitted /// handshake stays byte-for-byte identical to the pinned clean-boot shape. -pub fn build_handshake_with_capabilities( +/// +/// CFG-12: `settings.updated` resolves [`WsState::handshake_settings`] — the +/// LIVE tree — fresh on every call (one call per `/ws` connection), matching +/// the original's per-connection snapshot provider. On a clean boot the lock +/// contents equal the old frozen snapshot, so the emitted bytes are +/// unchanged; what changes is that a PATCH committed after boot now reaches +/// the NEXT connection. +pub async fn build_handshake_with_capabilities( state: &WsState, pane_reconcile_v1: bool, pane_reconcile_fresh_agent_v1: bool, @@ -484,7 +538,7 @@ pub fn build_handshake_with_capabilities( ), }), ServerMessage::SettingsUpdated(SettingsUpdated { - settings: state.settings.as_ref().clone(), + settings: state.handshake_settings.read().await.clone(), }), ServerMessage::PerfLogging(PerfLogging { enabled: false }), ]; @@ -694,9 +748,12 @@ async fn handle_socket( .and_then(|v| v.as_bool()) .unwrap_or(false); - // Authenticated: emit the ordered handshake. + // Authenticated: emit the ordered handshake. CFG-12: the builder is + // async + per-connection so its `settings.updated` frame resolves the + // LIVE settings tree (see `build_handshake_with_capabilities`). for msg in build_handshake_with_capabilities(&state, pane_reconcile_v1, pane_reconcile_fresh_agent_v1) + .await { let json = match serde_json::to_string(&msg) { Ok(json) => json, @@ -800,29 +857,6 @@ mod tests { use super::*; use serde_json::json; - fn test_settings() -> ServerSettings { - // Minimal but structurally valid; the exact default tree is pinned by - // freshell-server's fixture test. Here we only need SOMETHING to emit. - serde_json::from_value(json!({ - "ai": {}, - "codingCli": { "enabledProviders": [], "mcpServer": true, "providers": {} }, - "editor": { "externalEditor": "auto" }, - "extensions": { "disabled": [] }, - "freshAgent": { "defaultPlugins": [], "enabled": false, "providers": {} }, - "logging": { "debug": false }, - "network": { "configured": true, "host": "127.0.0.1" }, - "panes": { "defaultNewPane": "ask" }, - "safety": { "autoKillIdleMinutes": 15 }, - "sidebar": { - "autoGenerateTitles": true, - "excludeFirstChatMustStart": false, - "excludeFirstChatSubstrings": [] - }, - "terminal": { "scrollback": 10000 } - })) - .unwrap() - } - fn state() -> WsState { let auth_token = Arc::new("s3cr3t-token-abcdef".to_string()); let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(16).0); @@ -835,6 +869,7 @@ mod tests { server_instance_id: Arc::new("srv-1111".to_string()), boot_id: Arc::new("boot-2222".to_string()), settings: Arc::new(test_settings()), + handshake_settings: Arc::new(tokio::sync::RwLock::new(test_settings())), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), @@ -926,31 +961,31 @@ mod tests { /// ONLY for a hello that opted in — the default handshake stays /// byte-identical to the pinned clean-boot shape (frozen-client inertness /// at the source). - #[test] - fn handshake_advertises_pane_reconcile_only_when_negotiated() { + #[tokio::test] + async fn handshake_advertises_pane_reconcile_only_when_negotiated() { let s = state(); - let negotiated = build_handshake_with_capabilities(&s, true, false); + let negotiated = build_handshake_with_capabilities(&s, true, false).await; let ready = serde_json::to_value(&negotiated[0]).unwrap(); assert_eq!( ready["capabilities"], serde_json::json!({ "paneReconcileV1": true }) ); - let default = build_handshake(&s); + let default = build_handshake(&s).await; let ready = serde_json::to_value(&default[0]).unwrap(); assert!( ready.get("capabilities").is_none(), "non-negotiating hello must not change ready's shape: {ready}" ); // Same shape as an explicit `false` negotiation. - let unnegotiated = build_handshake_with_capabilities(&s, false, false); + let unnegotiated = build_handshake_with_capabilities(&s, false, false).await; let ready2 = serde_json::to_value(&unnegotiated[0]).unwrap(); assert!(ready2.get("capabilities").is_none()); } - #[test] - fn handshake_is_ordered_with_shared_bootid() { - let msgs = build_handshake(&state()); + #[tokio::test] + async fn handshake_is_ordered_with_shared_bootid() { + let msgs = build_handshake(&state()).await; let wire: Vec = msgs .iter() .map(|m| serde_json::to_value(m).unwrap()) @@ -985,14 +1020,14 @@ mod tests { /// back, `config.fallback` slots into the ordered handshake right after /// `perf.logging` and before `terminal.inventory` -- mirrors the /// original's exact ordering (`ws-handler.ts:1730-1735`). - #[test] - fn handshake_includes_config_fallback_when_boot_fell_back_and_in_correct_order() { + #[tokio::test] + async fn handshake_includes_config_fallback_when_boot_fell_back_and_in_correct_order() { let mut s = state(); s.config_fallback = Some(freshell_protocol::ConfigFallback { reason: freshell_protocol::ConfigFallbackReason::ParseError, backup_exists: true, }); - let msgs = build_handshake(&s); + let msgs = build_handshake(&s).await; let wire: Vec = msgs .iter() .map(|m| serde_json::to_value(m).unwrap()) @@ -1017,9 +1052,9 @@ mod tests { /// identical to before this fix (proves `handshake_is_ordered_with_ /// shared_bootid` above, asserting the 4-message shape, keeps passing /// unchanged). - #[test] - fn handshake_omits_config_fallback_when_boot_was_healthy() { - let msgs = build_handshake(&state()); + #[tokio::test] + async fn handshake_omits_config_fallback_when_boot_was_healthy() { + let msgs = build_handshake(&state()).await; assert!( !msgs .iter() @@ -1035,19 +1070,19 @@ mod tests { /// original achieves late-connect delivery too (per-connection /// `sendHandshakeSnapshot`, `ws-handler.ts:1723-1749`, recomputed on /// every hello rather than broadcast once at boot). - #[test] - fn handshake_delivers_config_fallback_identically_across_multiple_connections() { + #[tokio::test] + async fn handshake_delivers_config_fallback_identically_across_multiple_connections() { let mut s = state(); s.config_fallback = Some(freshell_protocol::ConfigFallback { reason: freshell_protocol::ConfigFallbackReason::Enoent, backup_exists: false, }); - let first_connection = build_handshake(&s); - // Simulate a client connecting much later: the SAME frozen WsState - // (nothing mutates it between connections) produces an identical + let first_connection = build_handshake(&s).await; + // Simulate a client connecting much later: with no settings mutation + // between connections, the live resolution produces an identical // handshake on a second, independent call. - let late_connection = build_handshake(&s); + let late_connection = build_handshake(&s).await; // DEFLAKE (f3wp): `ready.timestamp` is wall-clock at build time, so // two handshakes built across a millisecond boundary legitimately @@ -1085,6 +1120,59 @@ mod tests { ); } + /// CFG-12 RED/GREEN target: the handshake's `settings.updated` frame + /// resolves the LIVE settings tree per connection (the original's + /// per-connection `handshakeSnapshotProvider` awaits + /// `configStore.getSettings()` on EVERY `/ws` hello, `server/index.ts: + /// 415-427` + `ws-handler.ts:1815-1845`). A settings write committed + /// after boot (the PATCH path's committed value) must reach the NEXT + /// connection's handshake; a boot-frozen snapshot would leave every + /// later (re)connecting client resolving the pre-PATCH tree, and the + /// client's last-write-wins application of that frame erases the correct + /// value it already learned from `/api/bootstrap`. + #[tokio::test] + async fn handshake_settings_updated_reflects_live_writes_between_connections() { + let s = state(); + let settings_of = |msgs: &Vec| -> serde_json::Value { + serde_json::to_value( + msgs.iter() + .find_map(|m| match m { + ServerMessage::SettingsUpdated(u) => Some(u), + _ => None, + }) + .expect("handshake carries settings.updated"), + ) + .unwrap() + }; + + let first = build_handshake(&s).await; + assert!( + settings_of(&first)["settings"].get("defaultCwd").is_none(), + "the clean-boot fixture has no defaultCwd" + ); + + // The PATCH-committed write lands in the SAME live tree the handshake + // reads (freshell-server wires `SettingsStore::shared_settings_lock()` + // here -- one lock, no copies). + s.handshake_settings.write().await.default_cwd = Some("/tmp/shared-cwd".to_string()); + + let second = build_handshake(&s).await; + assert_eq!( + settings_of(&second)["settings"]["defaultCwd"], + json!("/tmp/shared-cwd"), + "a later connection's handshake must resolve the live tree, not the boot snapshot" + ); + + // Boundary pin (CFG-06 ownership): the create-time view stays + // boot-scoped -- `terminal.rs`'s create derivations keep reading the + // frozen field; merging the two fields is CFG-06's separate, + // per-consumer-proven move, not a side effect of this fix. + assert!( + s.settings.default_cwd.is_none(), + "the frozen create-time settings view must NOT follow the live lock" + ); + } + // `spawn_periodic` (TERM-11 idle-reaper scheduling primitive): proves the // REAL tokio ticker cadence, decoupled from `enforce_idle_kills`' domain // logic (already exhaustively unit-tested in `freshell-terminal`). diff --git a/crates/freshell-ws/src/opencode_association.rs b/crates/freshell-ws/src/opencode_association.rs index 29cb56643..16f8e6b46 100644 --- a/crates/freshell-ws/src/opencode_association.rs +++ b/crates/freshell-ws/src/opencode_association.rs @@ -413,26 +413,8 @@ mod tests { auth_token: StdArc::clone(&auth_token), server_instance_id: StdArc::new("srv-1111".to_string()), boot_id: StdArc::new("boot-2222".to_string()), - settings: StdArc::new( - serde_json::from_value(serde_json::json!({ - "ai": {}, - "codingCli": { "enabledProviders": [], "mcpServer": true, "providers": {} }, - "editor": { "externalEditor": "auto" }, - "extensions": { "disabled": [] }, - "freshAgent": { "defaultPlugins": [], "enabled": false, "providers": {} }, - "logging": { "debug": false }, - "network": { "configured": true, "host": "127.0.0.1" }, - "panes": { "defaultNewPane": "ask" }, - "safety": { "autoKillIdleMinutes": 15 }, - "sidebar": { - "autoGenerateTitles": true, - "excludeFirstChatMustStart": false, - "excludeFirstChatSubstrings": [] - }, - "terminal": { "scrollback": 10000 } - })) - .unwrap(), - ), + settings: StdArc::new(crate::test_settings()), + handshake_settings: StdArc::new(tokio::sync::RwLock::new(crate::test_settings())), broadcast_tx: StdArc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/src/tabs.rs b/crates/freshell-ws/src/tabs.rs index 56e761bc3..70f38e4ae 100644 --- a/crates/freshell-ws/src/tabs.rs +++ b/crates/freshell-ws/src/tabs.rs @@ -34,7 +34,6 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::{json, Value}; @@ -816,10 +815,11 @@ fn record_tab_key(record: &Value) -> Option { } pub(crate) fn now_ms() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) + // HARNESS-14: routed through the shared, env-gated test clock + // (`freshell_platform::clock`; gate-off identity passthrough), so the + // 7-day device-display TTL cutoff and the push-time `capturedAt` + // retention stamps all follow the one clock a spec controls. + freshell_platform::clock::now_ms() } /// Extract the `records` array from a `tabs.sync.push` envelope (empty if absent). diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index a354fdc84..98e1354fb 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -46,6 +46,7 @@ use axum::extract::ws::{Message, WebSocket}; use futures_util::stream::SplitSink; use futures_util::{SinkExt, StreamExt}; use tokio::sync::mpsc; +use tracing::Instrument; use uuid::Uuid; use freshell_platform::detect::{host_os_live, is_windows, is_wsl_env_live}; @@ -98,6 +99,79 @@ pub(crate) fn now_ms() -> i64 { .unwrap_or(0) } +/// DIAG-01 connection ownership: run the whole serve loop inside a +/// per-connection span, so EVERY event emitted while serving this +/// connection carries `connection_id` + `origin_kind` when the server-side +/// JsonLayer flattens span fields into the JSONL line. +/// +/// The span is created at ERROR level ON PURPOSE (this is context +/// infrastructure, not a message: our JsonLayer never renders span +/// open/close, so the level has zero output effect). An INFO-level span is +/// silently disabled by an operator's `RUST_LOG=warn`/`error` filter, and +/// once disabled its fields vanish from the scope of the very WARN/ERROR +/// in-connection events that still get logged (empirically confirmed: +/// `ws.keepalive.terminated` & friends would lose `connection_id` exactly +/// when an operator cranks the filter up to investigate). ERROR is the one +/// level enabled under EVERY filter that still admits any events at all +/// (a filter stricter than `error` logs nothing for the span to enrich). +pub(crate) fn connection_span(conn_id: u64, origin_kind: &'static str) -> tracing::Span { + tracing::span!( + tracing::Level::ERROR, + "ws_conn", + connection_id = conn_id, + origin_kind = origin_kind, + ) +} + +/// DIAG-01 dual-carrier connection ownership. The `ws_conn` span +/// ([`connection_span`]) enriches in-connection events under bare / +/// globally-anchored level filters, but tracing-subscriber disables SPAN +/// callsites under target-directive-only filters (empirically: even a +/// matched `freshell_ws=info` directive disables the span while still +/// admitting that crate's events). An event's own fields ride through ANY +/// filter that admits the event itself -- so the create-reply join +/// (connection_id <-> requestId <-> terminal_id) is ALSO emitted as +/// explicit event fields here, once per `terminal.created` reply path +/// (`path` says which). Under a filter that silences freshell_ws entirely +/// (`freshell_ws=off`), nothing in this crate logs at all by the operator's +/// express choice; the registry's `terminal.created` then joins only via +/// `terminal_id` (documented in freshell-server's logging.rs schema). +pub(crate) fn log_create_settled( + conn_id: u64, + request_id: &str, + terminal_id: &str, + path: &'static str, +) { + tracing::info!( + connection_id = conn_id, + request_id = %request_id, + terminal_id = %terminal_id, + path = path, + "ws.terminal.create.settled" + ); +} + +/// `tokio::task::spawn_blocking` does NOT propagate the caller's tracing +/// span context across the thread hop (spans are thread-local), which would +/// silently strip `connection_id` (and any future request-scoped context) +/// from every event emitted inside the closure -- e.g. the registry's +/// `terminal.created`, fired from `handle_create`'s blocking PTY spawn +/// (DIAG-01 connection ownership). This helper carries the CURRENT span +/// into the blocking closure and enters it for the closure's duration -- +/// the canonical correct use of `Span::enter` (a synchronous guard, never +/// held across an `.await`). +fn spawn_blocking_in_span(f: F) -> tokio::task::JoinHandle +where + F: FnOnce() -> R + Send + 'static, + R: Send + 'static, +{ + let span = tracing::Span::current(); + tokio::task::spawn_blocking(move || { + let _entered = span.enter(); + f() + }) +} + /// The modes that get a spawn-time pending marker: EXACTLY the modes with a /// registered post-spawn identity resolver (codex candidate adoption, the /// opencode locator sweep; amplifier's post-spawn locator was deleted — @@ -130,14 +204,14 @@ fn map_shell(shell: Shell) -> ShellType { pub async fn run( socket: WebSocket, state: &WsState, - mut bcast_rx: tokio::sync::broadcast::Receiver, + bcast_rx: tokio::sync::broadcast::Receiver, terminal_output_batch_v1: bool, ui_screenshot_v1: bool, pane_reconcile_v1: bool, pane_reconcile_fresh_agent_v1: bool, origin_kind: &'static str, ) { - let (mut ws_tx, mut ws_rx) = socket.split(); + let (ws_tx, ws_rx) = socket.split(); // Identify this connection so the registry can key its terminal subscriptions // (and sweep them on close). @@ -153,6 +227,49 @@ pub async fn run( "ws.connection.established" ); + // DIAG-01 connection ownership: run the whole serve loop inside a + // per-connection span, so EVERY event emitted while serving this + // connection (same-task or, via the `.instrument(Span::current())` / + // `spawn_blocking_in_span` hop sites below, spawned-task and + // blocking-pool work) carries `connection_id` + `origin_kind` when the + // server-side JsonLayer flattens span fields into the JSONL line. A bare + // `span.enter()` held across `.await`s would instead leak the span into + // unrelated tasks parked on the same OS thread -- hence `.instrument()` + // on the loop future and explicit context hops at thread boundaries. + // `connection_span`'s own doc explains why the span is ERROR-level. + let span = connection_span(conn_id, origin_kind); + run_loop( + ws_tx, + ws_rx, + state, + bcast_rx, + terminal_output_batch_v1, + ui_screenshot_v1, + pane_reconcile_v1, + pane_reconcile_fresh_agent_v1, + conn_id, + origin_kind, + ) + .instrument(span) + .await; +} + +/// The body of one connection's serve loop (`run` minus the connection-id +/// mint, the established event, and the span shell above). Everything in +/// here polls with the `ws_conn` span as the current context. +#[allow(clippy::too_many_arguments)] // Same connection-scoped plumbing as run(). +async fn run_loop( + mut ws_tx: WsSink, + mut ws_rx: futures_util::stream::SplitStream, + state: &WsState, + mut bcast_rx: tokio::sync::broadcast::Receiver, + terminal_output_batch_v1: bool, + ui_screenshot_v1: bool, + pane_reconcile_v1: bool, + pane_reconcile_fresh_agent_v1: bool, + conn_id: u64, + origin_kind: &'static str, +) { // This connection's single outbound channel. The registry delivers this // connection's attach.ready / replay / live-output / exit frames here (via the // FrameSink below); the loop drains it to the socket in FIFO — hence in-order. @@ -438,16 +555,21 @@ pub async fn run( } // DIAG-01: one summary lifecycle event per connection teardown, whatever - // the actual reason -- see `close_reason`/`close_code` above. + // the actual reason -- see `close_reason`/`close_code` above. Both + // identity fields are EVENT-level (not span-only): the dual-carrier + // doctrine — under target-directive-only RUST_LOG filters the ws_conn + // span's copies vanish while the event's own fields still land. match close_code { Some(code) => tracing::info!( connection_id = conn_id, + origin_kind = origin_kind, reason = close_reason, code = code, "ws.connection.closed" ), None => tracing::info!( connection_id = conn_id, + origin_kind = origin_kind, reason = close_reason, "ws.connection.closed" ), @@ -571,14 +693,24 @@ async fn handle_client_text( // paneReconcileV1 adopt/sessionRef-lease branches inside `handle_create`, // so a resend during any of those windows is answered from here instead // of re-entering the create path. - match state - .create_dedupe - .begin(&create.request_id, conn_sink, create.restore, |tid| { - state.registry.is_pty_running(tid) - }) { + match state.create_dedupe.begin( + &create.request_id, + conn_sink, + create.restore, + |tid| state.registry.is_pty_running(tid), + conn_id, + ) { crate::create_dedupe::DedupeDecision::DuplicateSettled(created) => { // Re-send the original terminal.created (same requestId, // same terminalId) — never spawn a duplicate. + if let ServerMessage::TerminalCreated(created_terminal) = &created { + log_create_settled( + conn_id, + &created_terminal.request_id, + &created_terminal.terminal_id, + "duplicate_settled", + ); + } let mut out = crate::create_gate::CreateOutput::Socket(ws_tx); return out.send(&created).await; } @@ -728,16 +860,25 @@ async fn handle_client_text( match create.provider { Some(freshell_protocol::AgentProvider::Codex) => { let fresh_codex = state.fresh_codex.clone(); - tokio::spawn(async move { fresh_codex.handle_create(create).await }); + tokio::spawn( + async move { fresh_codex.handle_create(create).await } + .instrument(tracing::Span::current()), + ); } Some(freshell_protocol::AgentProvider::Claude) => { let fresh_claude = state.fresh_claude.clone(); - tokio::spawn(async move { fresh_claude.handle_create(create).await }); + tokio::spawn( + async move { fresh_claude.handle_create(create).await } + .instrument(tracing::Span::current()), + ); } // Batch D PR-2: freshopencode joins the codex/claude WS create path. Some(freshell_protocol::AgentProvider::Opencode) => { let fresh_opencode = state.fresh_opencode.clone(); - tokio::spawn(async move { fresh_opencode.handle_create(create).await }); + tokio::spawn( + async move { fresh_opencode.handle_create(create).await } + .instrument(tracing::Span::current()), + ); } _ => {} } @@ -757,15 +898,24 @@ async fn handle_client_text( match attach.provider { freshell_protocol::AgentProvider::Codex => { let fresh_codex = state.fresh_codex.clone(); - tokio::spawn(async move { fresh_codex.handle_attach(attach).await }); + tokio::spawn( + async move { fresh_codex.handle_attach(attach).await } + .instrument(tracing::Span::current()), + ); } freshell_protocol::AgentProvider::Claude => { let fresh_claude = state.fresh_claude.clone(); - tokio::spawn(async move { fresh_claude.handle_attach(attach).await }); + tokio::spawn( + async move { fresh_claude.handle_attach(attach).await } + .instrument(tracing::Span::current()), + ); } freshell_protocol::AgentProvider::Opencode => { let fresh_opencode = state.fresh_opencode.clone(); - tokio::spawn(async move { fresh_opencode.handle_attach(attach).await }); + tokio::spawn( + async move { fresh_opencode.handle_attach(attach).await } + .instrument(tracing::Span::current()), + ); } _ => {} } @@ -775,16 +925,25 @@ async fn handle_client_text( match send.provider { freshell_protocol::AgentProvider::Codex => { let fresh_codex = state.fresh_codex.clone(); - tokio::spawn(async move { fresh_codex.handle_send(send).await }); + tokio::spawn( + async move { fresh_codex.handle_send(send).await } + .instrument(tracing::Span::current()), + ); } freshell_protocol::AgentProvider::Claude => { let fresh_claude = state.fresh_claude.clone(); - tokio::spawn(async move { fresh_claude.handle_send(send).await }); + tokio::spawn( + async move { fresh_claude.handle_send(send).await } + .instrument(tracing::Span::current()), + ); } // Batch D PR-2: materialize-or-send (the continuity fix) runs here. freshell_protocol::AgentProvider::Opencode => { let fresh_opencode = state.fresh_opencode.clone(); - tokio::spawn(async move { fresh_opencode.handle_send(send).await }); + tokio::spawn( + async move { fresh_opencode.handle_send(send).await } + .instrument(tracing::Span::current()), + ); } // `amplifier` exists on AgentProvider for the TERM-16 // terminal.turn.complete broadcast only — there is no @@ -810,26 +969,44 @@ async fn handle_client_text( ClientMessage::FreshAgentInterrupt(interrupt) => { if is_codex_provider(interrupt.provider) { let fresh_codex = state.fresh_codex.clone(); - tokio::spawn(async move { fresh_codex.handle_interrupt(interrupt).await }); + tokio::spawn( + async move { fresh_codex.handle_interrupt(interrupt).await } + .instrument(tracing::Span::current()), + ); } else if interrupt.provider == freshell_protocol::AgentProvider::Claude { let fresh_claude = state.fresh_claude.clone(); - tokio::spawn(async move { fresh_claude.handle_interrupt(interrupt).await }); + tokio::spawn( + async move { fresh_claude.handle_interrupt(interrupt).await } + .instrument(tracing::Span::current()), + ); } else if interrupt.provider == freshell_protocol::AgentProvider::Opencode { let fresh_opencode = state.fresh_opencode.clone(); - tokio::spawn(async move { fresh_opencode.handle_interrupt(interrupt).await }); + tokio::spawn( + async move { fresh_opencode.handle_interrupt(interrupt).await } + .instrument(tracing::Span::current()), + ); } true } ClientMessage::FreshAgentKill(kill) => { if is_codex_provider(kill.provider) { let fresh_codex = state.fresh_codex.clone(); - tokio::spawn(async move { fresh_codex.handle_kill(kill).await }); + tokio::spawn( + async move { fresh_codex.handle_kill(kill).await } + .instrument(tracing::Span::current()), + ); } else if kill.provider == freshell_protocol::AgentProvider::Claude { let fresh_claude = state.fresh_claude.clone(); - tokio::spawn(async move { fresh_claude.handle_kill(kill).await }); + tokio::spawn( + async move { fresh_claude.handle_kill(kill).await } + .instrument(tracing::Span::current()), + ); } else if kill.provider == freshell_protocol::AgentProvider::Opencode { let fresh_opencode = state.fresh_opencode.clone(); - tokio::spawn(async move { fresh_opencode.handle_kill(kill).await }); + tokio::spawn( + async move { fresh_opencode.handle_kill(kill).await } + .instrument(tracing::Span::current()), + ); } true } @@ -975,11 +1152,11 @@ async fn handle_client_text( ) .await } + // AUTO-01: the connected UI's layout mirror + // (`src/store/layoutMirrorMiddleware.ts`) feeds the shared // Deliberately inert remainder -- every arm here is unreachable from the // frozen client's live surface: `hello` was already consumed by the - // pre-loop handshake (`evaluate_hello`); `ui.layout.sync` is not consumed - // anywhere in this port yet (documented deferral -- - // freshell-freshagent/src/pane_ops.rs `resize_pane`); `codingcli.*` has + // pre-loop handshake (`evaluate_hello`); `codingcli.*` has // no runtime here and the frozen client never sends it (zero senders in // `src/`). The user-reachable fresh-agent control frames // (approval.respond / question.respond / fork / compact) are answered @@ -1735,7 +1912,7 @@ async fn gate_wire_resume( let mode_for_gate = mode.to_string(); let rid = resume_session_id.take(); let intent = *launch_intent; - tokio::task::spawn_blocking(move || { + spawn_blocking_in_span(move || { crate::resume_validation::validate_wire_resume( &mode_for_gate, rid, @@ -1763,8 +1940,8 @@ async fn gate_wire_resume( let ledger = std::sync::Arc::clone(&state.pane_ledger); let retire_mode = mode.to_string(); let stale_id = stale.to_string(); - let _ = tokio::task::spawn_blocking(move || ledger.retire_missing(&retire_mode, &stale_id)) - .await; + let _ = + spawn_blocking_in_span(move || ledger.retire_missing(&retire_mode, &stale_id)).await; } ResumeGateCarry { notice: outcome.notice, @@ -1907,6 +2084,10 @@ pub(crate) async fn handle_create( create_request_id = %create.request_id, "terminal.create.adopted" ); + log_create_settled(conn_id, &create.request_id, &existing, "adopted"); + // Clone before the struct literal moves `create.request_id` + // (same discipline as the main spawn path's dedupe locals). + let dedupe_request_id = create.request_id.clone(); let created = ServerMessage::TerminalCreated(TerminalCreated { created_at: now_ms(), request_id: create.request_id, @@ -1917,6 +2098,21 @@ pub(crate) async fn handle_create( restore_error: None, session_ref: state.identity.session_ref_for(&existing), }); + // An adoption IS a successful create for this requestId: + // settle the server-wide dedupe entry exactly like the main + // spawn path. Without it, the caller's `clear_if_in_flight` + // drops the still-InFlight sentinel and answers any + // cross-connection waiters with PTY_SPAWN_FAILED despite the + // success — and a later same-requestId resend begins fresh + // instead of replaying, letting a NON-negotiated (frozen) + // connection's blind resend spawn a duplicate PTY. + state.create_dedupe.settle( + &dedupe_request_id, + &existing, + &created, + create.restore, + |tid| state.registry.is_pty_running(tid), + ); return out.send(&created).await; } if state.registry.begin_keyed_create(&create.request_id) { @@ -1972,6 +2168,16 @@ pub(crate) async fn handle_create( session_id = %locator.session_id, "terminal.create.session_ref_attached" ); + log_create_settled( + conn_id, + &create.request_id, + &terminal_id, + "session_ref_attached", + ); + // Clone before the struct literal moves + // `create.request_id` (same discipline as the main + // spawn path's dedupe locals). + let dedupe_request_id = create.request_id.clone(); let created = ServerMessage::TerminalCreated(TerminalCreated { created_at: now_ms(), request_id: create.request_id, @@ -1985,6 +2191,22 @@ pub(crate) async fn handle_create( .session_ref_for(&terminal_id) .or(Some(locator)), }); + // Attaching to the winner IS a successful create for + // this requestId: settle the dedupe entry exactly + // like the §5.4 adopt path above and the main spawn + // path — otherwise the caller's `clear_if_in_flight` + // errors any same-requestId waiters with + // PTY_SPAWN_FAILED despite the success, and a later + // resend on a NON-negotiated connection re-enters + // handle_create and spawns a duplicate PTY for the + // session. + state.create_dedupe.settle( + &dedupe_request_id, + &terminal_id, + &created, + create.restore, + |tid| state.registry.is_pty_running(tid), + ); return out.send(&created).await; } SessionRefClaim::Held { retry_after_ms } => { @@ -2692,7 +2914,7 @@ pub(crate) async fn handle_create( let write_cwd = spec.cwd.clone(); let write_request_id = create.request_id.clone(); let now = now_ms(); - let result = tokio::task::spawn_blocking(move || { + let result = spawn_blocking_in_span(move || { ledger.record_binding(&crate::pane_ledger::BindingWrite { provider: "claude", session_id: &write_session_id, @@ -2721,7 +2943,7 @@ pub(crate) async fn handle_create( let spawn_resume_session_id = resume_session_id.clone(); let spawn_create_request_id = create.request_id.clone(); // PIN2_PTY_SPAWN_ANCHOR: the spawn makes preallocated identity observable. - let create_result = match tokio::task::spawn_blocking(move || { + let create_result = match spawn_blocking_in_span(move || { registry.create( &spawn_spec, &child_env, @@ -2757,7 +2979,7 @@ pub(crate) async fn handle_create( if let Some(session_id) = resume_session_id.as_deref() { let ledger = std::sync::Arc::clone(&state.pane_ledger); let delete_session_id = session_id.to_string(); - let result = tokio::task::spawn_blocking(move || { + let result = spawn_blocking_in_span(move || { ledger.delete_binding("claude", &delete_session_id) }) .await @@ -2917,7 +3139,7 @@ pub(crate) async fn handle_create( // stream, so the locator never ARMS for them (suppressed inside // `maybe_arm` -- never via `locator.disarm`). let managed_codex = codex_remote_ws_url.is_some(); - let _ = tokio::task::spawn_blocking(move || { + let _ = spawn_blocking_in_span(move || { crate::codex_association::maybe_arm( &state, &terminal_id, @@ -3011,7 +3233,7 @@ pub(crate) async fn handle_create( let write_cwd = record.cwd.clone(); let write_request_id = create.request_id.clone(); let now = now_ms(); - let result = tokio::task::spawn_blocking(move || { + let result = spawn_blocking_in_span(move || { ledger.record_binding(&crate::pane_ledger::BindingWrite { provider: &provider, session_id: &session_id, @@ -3038,7 +3260,7 @@ pub(crate) async fn handle_create( let write_mode = mode.clone(); let write_cwd = spec.cwd.clone(); let now = now_ms(); - let result = tokio::task::spawn_blocking(move || { + let result = spawn_blocking_in_span(move || { ledger.record_pending(&write_terminal_id, &write_mode, write_cwd.as_deref(), now) }) .await @@ -3128,6 +3350,7 @@ pub(crate) async fn handle_create( dedupe_restore, |tid| state.registry.is_pty_running(tid), ); + log_create_settled(conn_id, &dedupe_request_id, &dedupe_terminal_id, "spawned"); let sent = out.send(&created).await; // "Notify all clients that list changed" (`ws-handler.ts:2570`); the original's // failed-delivery arm (`ws:2553`) broadcasts too, so once the terminal record @@ -3243,7 +3466,7 @@ pub async fn respawn_agent_terminal( let probe = state.session_existence.clone(); let gate_mode = mode.clone(); let sid = Some(req.session_id.clone()); - tokio::task::spawn_blocking(move || { + spawn_blocking_in_span(move || { crate::resume_validation::validate_wire_resume( &gate_mode, sid, @@ -3268,10 +3491,9 @@ pub async fn respawn_agent_terminal( let ledger = std::sync::Arc::clone(&state.pane_ledger); let retire_provider = req.provider.clone(); let stale_id = stale.to_string(); - let _ = tokio::task::spawn_blocking(move || { - ledger.retire_missing(&retire_provider, &stale_id) - }) - .await; + let _ = + spawn_blocking_in_span(move || ledger.retire_missing(&retire_provider, &stale_id)) + .await; } // Headless path: no per-create `out` sink. Broadcast the existing // Recovering status frame (precedent: auto_resume.rs emit_recovering) @@ -3538,7 +3760,7 @@ pub async fn respawn_agent_terminal( let spawn_mode = mode.clone(); let spawn_resume_session_id = resume_session_id.clone(); let spawn_create_request_id = req.create_request_id.clone(); - let create_result = match tokio::task::spawn_blocking(move || { + let create_result = match spawn_blocking_in_span(move || { registry.create( &spawn_spec, &child_env, @@ -3633,7 +3855,7 @@ pub async fn respawn_agent_terminal( // stream, so the locator never ARMS for them (suppressed inside // `maybe_arm` -- never via `locator.disarm`). let managed_codex = codex_remote_ws_url.is_some(); - let _ = tokio::task::spawn_blocking(move || { + let _ = spawn_blocking_in_span(move || { crate::codex_association::maybe_arm( &state, &terminal_id, @@ -3677,7 +3899,7 @@ pub async fn respawn_agent_terminal( let write_cwd = record.cwd.clone(); let write_request_id = req.create_request_id.clone(); let now = now_ms(); - let result = tokio::task::spawn_blocking(move || { + let result = spawn_blocking_in_span(move || { ledger.record_binding(&crate::pane_ledger::BindingWrite { provider: &provider, session_id: &session_id, @@ -4550,7 +4772,7 @@ async fn handle_kill(kill: TerminalKill, ws_tx: &mut WsSink, state: &WsState) -> let ledger = std::sync::Arc::clone(&state.pane_ledger); let tid = kill.terminal_id.clone(); let now = now_ms(); - let _ = tokio::task::spawn_blocking(move || { + let _ = spawn_blocking_in_span(move || { if let Some(sref) = sref { if let Err(err) = ledger.retire_closed(&sref.provider, &sref.session_id, now) { tracing::warn!(terminal_id = %tid, error = %err, "pane_ledger_retire_failed_on_kill"); @@ -4686,7 +4908,7 @@ async fn process_tabs_push( // the small `&str` args as `String`s and move the whole call into // `spawn_blocking` (`TabsRegistry` is `Clone`/`Arc`-backed; `records` is // already owned). - let joined = tokio::task::spawn_blocking(move || { + let joined = spawn_blocking_in_span(move || { reg.replace_client_snapshot( &server_instance_id, &device_id, @@ -5524,26 +5746,8 @@ mod terminals_changed_tests { auth_token: Arc::clone(&auth_token), server_instance_id: Arc::new("srv-1111".to_string()), boot_id: Arc::new("boot-2222".to_string()), - settings: Arc::new( - serde_json::from_value(serde_json::json!({ - "ai": {}, - "codingCli": { "enabledProviders": [], "mcpServer": true, "providers": {} }, - "editor": { "externalEditor": "auto" }, - "extensions": { "disabled": [] }, - "freshAgent": { "defaultPlugins": [], "enabled": false, "providers": {} }, - "logging": { "debug": false }, - "network": { "configured": true, "host": "127.0.0.1" }, - "panes": { "defaultNewPane": "ask" }, - "safety": { "autoKillIdleMinutes": 15 }, - "sidebar": { - "autoGenerateTitles": true, - "excludeFirstChatMustStart": false, - "excludeFirstChatSubstrings": [] - }, - "terminal": { "scrollback": 10000 } - })) - .unwrap(), - ), + settings: Arc::new(crate::test_settings()), + handshake_settings: Arc::new(tokio::sync::RwLock::new(crate::test_settings())), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), @@ -5772,26 +5976,10 @@ mod terminal_meta_created_tests { auth_token: std::sync::Arc::clone(&auth_token), server_instance_id: std::sync::Arc::new("srv-1111".to_string()), boot_id: std::sync::Arc::new("boot-2222".to_string()), - settings: std::sync::Arc::new( - serde_json::from_value(serde_json::json!({ - "ai": {}, - "codingCli": { "enabledProviders": [], "mcpServer": true, "providers": {} }, - "editor": { "externalEditor": "auto" }, - "extensions": { "disabled": [] }, - "freshAgent": { "defaultPlugins": [], "enabled": false, "providers": {} }, - "logging": { "debug": false }, - "network": { "configured": true, "host": "127.0.0.1" }, - "panes": { "defaultNewPane": "ask" }, - "safety": { "autoKillIdleMinutes": 15 }, - "sidebar": { - "autoGenerateTitles": true, - "excludeFirstChatMustStart": false, - "excludeFirstChatSubstrings": [] - }, - "terminal": { "scrollback": 10000 } - })) - .unwrap(), - ), + settings: std::sync::Arc::new(crate::test_settings()), + handshake_settings: std::sync::Arc::new(tokio::sync::RwLock::new( + crate::test_settings(), + )), broadcast_tx: std::sync::Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), @@ -6074,3 +6262,229 @@ mod terminal_dims_range_tests { ); } } + +#[cfg(test)] +mod connection_span_filter_tests { + //! Regression for the fresh-eyes review finding: the `ws_conn` span is + //! context infrastructure, so its fields must survive ANY operator level + //! filter (`RUST_LOG`) that still admits events. An INFO-level span is + //! silently disabled by `warn`/`error` filters, stripping `connection_id` + //! from the very in-connection WARN/ERROR events an operator cranks the + //! filter up to inspect -- empirically confirmed during review with an + //! info-level probe (empty field set under a `warn` EnvFilter), then + //! fixed by creating the span at ERROR level (`connection_span`). + + use std::collections::BTreeMap; + use std::sync::{Arc, Mutex}; + + use tracing::field::{Field, Visit}; + use tracing::span::{Attributes, Id}; + use tracing::{Event, Subscriber}; + use tracing_subscriber::layer::{Context, SubscriberExt}; + use tracing_subscriber::registry::LookupSpan; + use tracing_subscriber::Layer; + + #[derive(Debug, Clone, Default)] + struct Captured { + message: String, + fields: BTreeMap, + } + + #[derive(Default)] + struct Visitor { + message: String, + fields: BTreeMap, + } + + impl Visit for Visitor { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + let rendered = format!("{value:?}"); + if field.name() == "message" { + self.message = rendered; + } else { + self.fields.insert(field.name().to_string(), rendered); + } + } + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.message = value.to_string(); + } else { + self.fields + .insert(field.name().to_string(), value.to_string()); + } + } + fn record_u64(&mut self, field: &Field, value: u64) { + self.fields + .insert(field.name().to_string(), value.to_string()); + } + fn record_i64(&mut self, field: &Field, value: i64) { + self.fields + .insert(field.name().to_string(), value.to_string()); + } + } + + struct SpanFields(BTreeMap); + + struct Capture { + events: Arc>>, + } + + impl Layer for Capture + where + S: Subscriber + for<'a> LookupSpan<'a>, + { + fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) { + let mut visitor = Visitor::default(); + attrs.record(&mut visitor); + if let Some(span) = ctx.span(id) { + span.extensions_mut().insert(SpanFields(visitor.fields)); + } + } + + fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) { + let mut visitor = Visitor::default(); + event.record(&mut visitor); + let mut fields = BTreeMap::new(); + if let Some(scope) = ctx.event_scope(event) { + for span in scope.from_root() { + let extensions = span.extensions(); + if let Some(SpanFields(span_fields)) = extensions.get::() { + for (k, v) in span_fields { + fields.insert(k.clone(), v.clone()); + } + } + } + } + for (k, v) in visitor.fields { + fields.insert(k, v); + } + self.events.lock().expect("capture lock").push(Captured { + message: visitor.message, + fields, + }); + } + } + + #[test] + fn ws_conn_span_fields_survive_every_operator_level_filter() { + // Any filter admitting events: error/warn admit WARN, INFO and finer + // admit INFO. A filter that admits nothing (`off`) has nothing to + // enrich and is out of scope by construction. + for filter_level in ["error", "warn", "info", "debug", "trace"] { + let events = Arc::new(Mutex::new(Vec::new())); + let layer = Capture { + events: Arc::clone(&events), + }; + let filter = tracing_subscriber::EnvFilter::new(filter_level); + let subscriber = tracing_subscriber::registry().with(filter).with(layer); + tracing::subscriber::with_default(subscriber, || { + let span = super::connection_span(7u64, "same-origin"); + let _e = span.enter(); + match filter_level { + "error" => tracing::error!("probe_event"), + "warn" => tracing::warn!("probe_event"), + _ => tracing::info!("probe_event"), + } + }); + let captured = events.lock().unwrap().clone(); + let probe = captured + .iter() + .find(|e| e.message == "probe_event") + .unwrap_or_else(|| { + panic!("probe event must be captured under a {filter_level} filter") + }); + assert_eq!( + probe.fields.get("connection_id").map(String::as_str), + Some("7"), + "connection_id must survive a RUST_LOG={filter_level} filter" + ); + assert_eq!( + probe.fields.get("origin_kind").map(String::as_str), + Some("same-origin"), + "origin_kind must survive a RUST_LOG={filter_level} filter" + ); + } + } + + /// Target-directive behavior, empirically mapped (probe, 2026-08-09) — + /// review round 2's finding. tracing-subscriber disables SPAN callsites + /// under TARGET-DIRECTIVE-ONLY filters, even when a directive names the + /// span's own component at an admitting level (`freshell_ws=info` + /// still kills the span). Under GLOBALLY-ANCHORED mixes (`info, + /// freshell_terminal=debug`) the span is enabled fine. This test + /// therefore pins the guarantees, not the pathologies: + /// (a) a globally-anchored target mix keeps the span enriching a + /// cross-crate in-connection event; + /// (b) under a target-directive-only mix, the ws-side settle event + /// ([`super::log_create_settled`]) still carries the join as + /// EVENT fields, which no filter config can strip. (Empirically + /// the span's fields are absent in this scenario; that absence is + /// tracing-subscriber's per-callsite span-interest behavior, NOT + /// a guarantee of ours, so it is documented here but not + /// asserted.) + /// (c) A filter silencing freshell_ws (`freshell_ws=off`) is the + /// documented boundary: nothing in this crate logs at all then, + /// by the operator's express choice. + #[test] + fn ws_conn_context_guarantee_envelope_across_filter_shapes() { + // (a) globally-anchored target mix: span fields merge onto a + // cross-crate in-connection event admitted at info. + { + let events = Arc::new(Mutex::new(Vec::new())); + let layer = Capture { + events: Arc::clone(&events), + }; + let filter = tracing_subscriber::EnvFilter::new("info,freshell_terminal=debug"); + let subscriber = tracing_subscriber::registry().with(filter).with(layer); + tracing::subscriber::with_default(subscriber, || { + let span = super::connection_span(7u64, "same-origin"); + let _e = span.enter(); + tracing::info!(target: "freshell_terminal::registry", "probe_event"); + }); + let captured = events.lock().unwrap().clone(); + let probe = captured + .iter() + .find(|e| e.message == "probe_event") + .expect("probe event captured under globally-anchored mix"); + assert_eq!( + probe.fields.get("connection_id").map(String::as_str), + Some("7"), + "globally-anchored mixes keep span-based context" + ); + } + // (b) target-directive-only mix: the event-level dual carrier + // survives where span context does not. + { + let events = Arc::new(Mutex::new(Vec::new())); + let layer = Capture { + events: Arc::clone(&events), + }; + let filter = + tracing_subscriber::EnvFilter::new("freshell_ws=info,freshell_terminal=debug"); + let subscriber = tracing_subscriber::registry().with(filter).with(layer); + tracing::subscriber::with_default(subscriber, || { + let span = super::connection_span(7u64, "same-origin"); + let _e = span.enter(); + super::log_create_settled(7u64, "req-123", "term-xyz", "spawned"); + }); + let captured = events.lock().unwrap().clone(); + let settled = captured + .iter() + .find(|e| e.message == "ws.terminal.create.settled") + .expect("settle event captured under target-directive-only mix"); + assert_eq!( + settled.fields.get("connection_id").map(String::as_str), + Some("7"), + "event-level connection_id rides through ANY filter admitting the event" + ); + assert_eq!( + settled.fields.get("request_id").map(String::as_str), + Some("req-123") + ); + assert_eq!( + settled.fields.get("terminal_id").map(String::as_str), + Some("term-xyz") + ); + } + } +} diff --git a/crates/freshell-ws/tests/auto_resume_respawn.rs b/crates/freshell-ws/tests/auto_resume_respawn.rs index dbb3fe79f..6a157ac94 100644 --- a/crates/freshell-ws/tests/auto_resume_respawn.rs +++ b/crates/freshell-ws/tests/auto_resume_respawn.rs @@ -375,6 +375,7 @@ fn respawn_state_with_probe( server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: common::handshake_settings_lock(), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/claude_session_rebind.rs b/crates/freshell-ws/tests/claude_session_rebind.rs index b42e508ee..09aad9359 100644 --- a/crates/freshell-ws/tests/claude_session_rebind.rs +++ b/crates/freshell-ws/tests/claude_session_rebind.rs @@ -149,6 +149,7 @@ async fn spawn_server_returning_state( server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: common::handshake_settings_lock(), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/codex_managed_launch_e2e.rs b/crates/freshell-ws/tests/codex_managed_launch_e2e.rs index 8dd7e5589..b1a94fb47 100644 --- a/crates/freshell-ws/tests/codex_managed_launch_e2e.rs +++ b/crates/freshell-ws/tests/codex_managed_launch_e2e.rs @@ -129,6 +129,9 @@ async fn spawn_server() -> (String, freshell_terminal::TerminalRegistry) { server_instance_id: Arc::new("srv-e2e".to_string()), boot_id: Arc::new("boot-e2e".to_string()), settings, + handshake_settings: Arc::new(tokio::sync::RwLock::new( + serde_json::from_value(test_settings_value()).expect("valid settings fixture"), + )), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/codex_session_ref_resume.rs b/crates/freshell-ws/tests/codex_session_ref_resume.rs index aedce4237..15935df8d 100644 --- a/crates/freshell-ws/tests/codex_session_ref_resume.rs +++ b/crates/freshell-ws/tests/codex_session_ref_resume.rs @@ -118,6 +118,9 @@ async fn spawn_server() -> (String, freshell_terminal::TerminalRegistry) { server_instance_id: Arc::new("srv-codex-session-ref".to_string()), boot_id: Arc::new("boot-codex-session-ref".to_string()), settings, + handshake_settings: Arc::new(tokio::sync::RwLock::new( + serde_json::from_value(test_settings_value()).expect("valid settings fixture"), + )), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/common/mod.rs b/crates/freshell-ws/tests/common/mod.rs index 47a552f1f..add7d7cb3 100644 --- a/crates/freshell-ws/tests/common/mod.rs +++ b/crates/freshell-ws/tests/common/mod.rs @@ -39,6 +39,19 @@ pub fn isolate_amplifier_home() -> std::path::PathBuf { .clone() } +/// CFG-12: the live handshake-settings handle for harness `WsState`s. Seeded +/// from the SAME fixture tree as the frozen `settings` field (clean-boot byte +/// parity), but independently mutable behind the lock: a test writing through +/// it changes what the NEXT `/ws` connection's handshake resolves — exactly +/// like a `PATCH /api/settings`-committed value in production, where +/// freshell-server wires `SettingsStore::shared_settings_lock()` into the +/// same slot (`crates/freshell-server/src/main.rs`). +pub fn handshake_settings_lock() -> Arc> { + Arc::new(tokio::sync::RwLock::new( + serde_json::from_value(test_settings_value()).expect("valid settings fixture"), + )) +} + pub fn test_settings_value() -> serde_json::Value { serde_json::json!({ "ai": {}, @@ -108,6 +121,94 @@ pub async fn spawn_server() -> (String, freshell_terminal::TerminalRegistry) { .await } +/// [`spawn_server_with_specs`], additionally handing back the LIVE +/// handshake-settings lock wired into `WsState.handshake_settings` (CFG-12), +/// so a test can mutate the tree between connections and assert what each +/// `/ws` handshake resolves. The frozen `settings` field is seeded +/// independently — mirroring prod, where create-time derivations stay +/// boot-scoped (CFG-06's separate boundary). +#[allow(dead_code)] // not every test binary uses the shared-settings variant +pub async fn spawn_server_with_specs_and_shared_settings( + cli_commands: Vec, +) -> ( + String, + freshell_terminal::TerminalRegistry, + Arc>, +) { + let _ = isolate_amplifier_home(); + let auth_token = Arc::new(AUTH_TOKEN.to_string()); + let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(64).0); + let settings = + Arc::new(serde_json::from_value(test_settings_value()).expect("valid settings fixture")); + let handshake_settings = handshake_settings_lock(); + let registry = freshell_terminal::TerminalRegistry::new(); + + let state = WsState { + layout: Default::default(), + terminal_meta: Default::default(), + pane_ledger: std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), + identity: freshell_ws::identity::TerminalIdentityRegistry::new(), + auth_token: Arc::clone(&auth_token), + server_instance_id: Arc::new("srv-test".to_string()), + boot_id: Arc::new("boot-test".to_string()), + settings, + handshake_settings: Arc::clone(&handshake_settings), + broadcast_tx: Arc::clone(&broadcast_tx), + auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, + auto_resume_cancels: Default::default(), + fresh_codex: freshell_freshagent::FreshCodexState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + serde_json::json!({ "freshAgent": { "enabled": false } }), + ), + fresh_claude: freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)), + fresh_opencode: freshell_freshagent::FreshOpencodeState::new( + freshell_freshagent::FreshAgentState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + ), + ), + registry: registry.clone(), + tabs: freshell_ws::tabs::TabsRegistry::new(), + screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), + terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + cli_commands: Arc::new(cli_commands), + shutdown: Arc::new(tokio::sync::Notify::new()), + ping_interval_ms: 30_000, + hello_timeout_ms: 5_000, + allowed_origins: Arc::new(freshell_ws::origin::default_allowed_origins()), + ws_max_payload_bytes: 16 * 1024 * 1024, + term09: freshell_ws::backpressure::Term09Config::default(), + create_protect: freshell_ws::create_limit::CreateProtectConfig::default(), + spawn_gate: std::sync::Arc::new(freshell_ws::spawn_gate::SpawnGate::new(4, 64)), + shutdown_started: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + create_dedupe: std::sync::Arc::new(freshell_ws::create_dedupe::CreateDedupe::default()), + config_fallback: None, + opencode_locator: None, + codex_locator: None, + activity: None, + session_existence: std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), + }; + + let router = freshell_ws::router(state); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral loopback port"); + let addr = listener.local_addr().expect("local addr"); + tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + ( + format!("ws://{addr}/ws", addr = addr), + registry, + handshake_settings, + ) +} + #[allow(dead_code)] // not every test binary uses the injectable variant pub async fn spawn_server_with_specs( cli_commands: Vec, @@ -129,6 +230,7 @@ pub async fn spawn_server_with_specs( server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: handshake_settings_lock(), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), @@ -208,6 +310,7 @@ pub async fn spawn_server_with_specs_and_auto_resume_rx( server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: handshake_settings_lock(), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx, auto_resume_cancels: Default::default(), @@ -291,6 +394,7 @@ pub async fn spawn_server_with_specs_and_auto_resume_hub( server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: handshake_settings_lock(), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx, auto_resume_cancels: Default::default(), @@ -371,6 +475,7 @@ pub async fn spawn_server_with_specs_and_state( server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: handshake_settings_lock(), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), @@ -459,6 +564,7 @@ pub async fn spawn_server_with_ledger( server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: handshake_settings_lock(), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), @@ -543,6 +649,7 @@ pub async fn spawn_server_with_specs_and_activity( server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: handshake_settings_lock(), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), @@ -626,6 +733,7 @@ pub async fn spawn_server_with_specs_activity_and_codex_locator( server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: handshake_settings_lock(), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), @@ -731,6 +839,7 @@ pub async fn spawn_server_with_create_protect_probes( server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: handshake_settings_lock(), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/create_dedupe.rs b/crates/freshell-ws/tests/create_dedupe.rs new file mode 100644 index 000000000..04ec4a19d --- /dev/null +++ b/crates/freshell-ws/tests/create_dedupe.rs @@ -0,0 +1,188 @@ +//! TERM-04 — `terminal.create` requestId dedupe, NON-RESTORE path, at the WS +//! wire boundary (real axum server, real PTYs, real tokio-tungstenite clients). +//! +//! The restore-path legs live in `restore_spawn_gate.rs` +//! (`same_requestid_resend_returns_existing_terminal`, +//! `resend_on_new_connection_*`); the guard's branch-level truths live in the +//! unit tests of `crates/freshell-ws/src/create_dedupe.rs`. This file closes +//! the remaining acceptance gap: the acceptance semantics are path-agnostic, +//! and the plain (non-restore) create path — inline `handle_create`, +//! `begin()` -> spawn -> `settle()`/`clear_if_in_flight` — had no end-to-end +//! dedupe proof of its own. Coverage map (checklist: retry, reconnect, +//! delayed responses, two clients): +//! +//! - `plain_resend_same_connection_replays_settled_terminal` — retry on the +//! same socket after settlement: replay, no respawn (the reply is a +//! `terminal.created`, so a RATE_LIMITED error frame would fail the await — +//! dedupe preceding the limiter is what makes this leg deterministic). +//! - `plain_resend_on_new_connection_replays_settled_terminal` — the lost +//! response: the first client's `terminal.created` arrived but its pane is +//! gone (socket dropped); the reconnect + second-client resend of the same +//! requestId must be answered with the SAME terminalId and exactly one PTY +//! must exist. (The pure in-flight-window waiter race is pinned by +//! `restore_spawn_gate.rs`'s deliberately-unawaited pair and the unit +//! suite's waiter tests; here the settled window is deterministic.) +//! +//! Contract note (legacy parity, `create_dedupe.rs` header): the replay +//! obligation holds while the first terminal is RUNNING; after an exit, a +//! re-sent requestId is indistinguishable from a fresh create and spawns a +//! new terminal. Both resend tests therefore assert the liveness +//! precondition explicitly so a dead-shell flake can never masquerade as a +//! dedupe violation. + +mod common; + +use common::{ + connect_and_capture_inventory, create_shell_terminal, next_frame_of_type, + spawn_server_with_create_protect_probes, +}; +use freshell_ws::create_limit::CreateProtectConfig; +use futures_util::SinkExt; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +/// The plain (non-restore) create frame — the shape the frozen client mints +/// first (TerminalView.tsx); identical bytes on every resend. +async fn send_plain_create(ws: &mut common::TestWs, request_id: &str) { + ws.send(WsMessage::Text( + serde_json::json!({ + "type": "terminal.create", + "requestId": request_id, + "mode": "shell", + "shell": "system", + }) + .to_string(), + )) + .await + .expect("send terminal.create"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn plain_resend_same_connection_replays_settled_terminal() { + let (ws_url, registry, _gate) = + spawn_server_with_create_protect_probes(CreateProtectConfig::default()).await; + let (mut ws, _inventory) = connect_and_capture_inventory(&ws_url).await; + + let tid = create_shell_terminal(&mut ws, "d-plain").await; + + // Explicit liveness precondition (see the module contract note): replay + // is owed only while the original terminal runs. + assert!( + registry.is_pty_running(&tid), + "test precondition: the original terminal must still be running" + ); + + // Blind resend on the same socket (the frozen client's retry ladder + // fires the identical frame until answered). Must replay the settled + // create — same terminalId — and must NOT trip the rate limiter (dedupe + // precedes it) or spawn a second PTY. + send_plain_create(&mut ws, "d-plain").await; + let second = next_frame_of_type(&mut ws, "terminal.created").await; + assert_eq!(second["requestId"], "d-plain"); + assert_eq!( + second["terminalId"], tid, + "same-requestId resend on one connection must replay the settled terminal" + ); + assert_eq!(registry.kill_all(), 1, "exactly one PTY for one requestId"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn plain_resend_on_new_connection_replays_settled_terminal() { + let (ws_url, registry, _gate) = + spawn_server_with_create_protect_probes(CreateProtectConfig::default()).await; + + let (mut c1, _inventory) = connect_and_capture_inventory(&ws_url).await; + let tid = create_shell_terminal(&mut c1, "d-xconn").await; + // The lost-response shape: the pane that asked is gone; its + // terminal.created may have arrived or not — the server keeps no + // per-connection debt. Only the requestId identity survives. + drop(c1); + + // Explicit liveness precondition (see the module contract note): replay + // is owed only while the original terminal runs. + assert!( + registry.is_pty_running(&tid), + "test precondition: the original terminal must still be running" + ); + + // The reconnect — and simultaneously the two-client shape: a different + // connection re-sends the identical create. + let (mut c2, _inventory) = connect_and_capture_inventory(&ws_url).await; + let tid2 = create_shell_terminal(&mut c2, "d-xconn").await; + assert_eq!( + tid2, tid, + "a settled plain create must replay its terminal.created on a new connection" + ); + + // The second connection's OWN resend still dedupes (the replay path is + // connection-neutral — the settled entry is server-global, legacy + // `createdTerminalByRequestId` parity). + send_plain_create(&mut c2, "d-xconn").await; + let third = next_frame_of_type(&mut c2, "terminal.created").await; + assert_eq!(third["terminalId"], tid); + + assert_eq!( + registry.kill_all(), + 1, + "exactly one PTY across reconnect + second client + repeat resend" + ); +} + +/// Wrap-review r3 wire pin: `restore` is optional on the wire and the SPA +/// omits it when false, so a resend that spells it explicitly +/// (`restore: false`) is the SAME request as the omitted-restore original. +/// Literal Option comparison treated them as a flag mismatch and let +/// the resend spawn a fresh PTY. The explicit-false resend on a NEW +/// connection must replay the settled terminal and spawn nothing. +#[tokio::test(flavor = "multi_thread")] +async fn explicit_restore_false_resend_replays_omitted_restore_settled() { + let (ws_url, registry, _gate) = + spawn_server_with_create_protect_probes(CreateProtectConfig::default()).await; + + // Original: no `restore` field at all (the SPA's actual wire shape). + let (mut c1, _inventory) = connect_and_capture_inventory(&ws_url).await; + let tid = create_shell_terminal(&mut c1, "d-rf").await; + assert!( + registry.is_pty_running(&tid), + "test precondition: the original terminal must still be running" + ); + drop(c1); + + // Resend on a new connection with the flag spelled out. + let (mut c2, _inventory) = connect_and_capture_inventory(&ws_url).await; + c2.send(WsMessage::Text( + serde_json::json!({ + "type": "terminal.create", + "requestId": "d-rf", + "mode": "shell", + "shell": "system", + "restore": false, + }) + .to_string(), + )) + .await + .expect("send explicit restore:false resend"); + let replay = next_frame_of_type(&mut c2, "terminal.created").await; + assert_eq!( + replay["terminalId"], tid, + "explicit restore:false must replay the omitted-restore settled terminal" + ); + assert_eq!( + registry.kill_all(), + 1, + "exactly one PTY — the differing spelling must not respawn" + ); +} + +/// Sanity guard for the harness wiring: an unrelated requestId is a DISTINCT +/// create (no over-dedupe). Two different requestIds → two terminals. +#[tokio::test(flavor = "multi_thread")] +async fn different_requestids_spawn_distinct_terminals() { + let (ws_url, registry, _gate) = + spawn_server_with_create_protect_probes(CreateProtectConfig::default()).await; + let (mut ws, _inventory) = connect_and_capture_inventory(&ws_url).await; + + let t1 = create_shell_terminal(&mut ws, "d-distinct-1").await; + let t2 = create_shell_terminal(&mut ws, "d-distinct-2").await; + assert_ne!(t1, t2, "distinct requestIds must never share a terminal"); + assert_eq!(registry.kill_all(), 2, "both distinct creates spawn"); +} diff --git a/crates/freshell-ws/tests/cross_kind_liveness.rs b/crates/freshell-ws/tests/cross_kind_liveness.rs index 18daf6c38..093aac740 100644 --- a/crates/freshell-ws/tests/cross_kind_liveness.rs +++ b/crates/freshell-ws/tests/cross_kind_liveness.rs @@ -253,6 +253,9 @@ async fn spawn_server() -> (String, freshell_terminal::TerminalRegistry) { server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: Arc::new(tokio::sync::RwLock::new( + serde_json::from_value(test_settings_value()).expect("valid settings fixture"), + )), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/diag01_lifecycle_events.rs b/crates/freshell-ws/tests/diag01_lifecycle_events.rs index bbf05409d..ebee2099e 100644 --- a/crates/freshell-ws/tests/diag01_lifecycle_events.rs +++ b/crates/freshell-ws/tests/diag01_lifecycle_events.rs @@ -19,14 +19,21 @@ const AUTH_TOKEN: &str = "s3cr3t-token-abcdef"; // ── capturing tracing layer (dev-only test facility) ────────────────────── use tracing::field::{Field, Visit}; +use tracing::span::{Attributes, Id}; use tracing::{Event, Subscriber}; use tracing_subscriber::layer::{Context, SubscriberExt}; +use tracing_subscriber::registry::LookupSpan; use tracing_subscriber::Layer; #[derive(Debug, Clone, Default)] struct CapturedEvent { message: String, + /// Span-merged view (span fields root->leaf, then event fields) — what + /// the production JsonLayer writes. fields: BTreeMap, + /// The event's OWN fields only (no span merge) — proves dual-carrier + /// claims, which require the field ON THE EVENT. + event_fields: BTreeMap, } #[derive(Default)] @@ -74,16 +81,49 @@ struct CaptureLayer { events: Arc>>, } -impl Layer for CaptureLayer { - fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { +/// Span-local storage mirroring the production `JsonLayer`'s `SpanFields`: +/// fields recorded at span creation are merged into every event captured +/// while that span is in the scope chain (root -> leaf; event fields win on +/// collision) -- exactly how `freshell-server`'s JSONL writer produces +/// `connection_id` on in-connection events. +struct SpanFields(BTreeMap); + +impl Layer for CaptureLayer +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) { + let mut visitor = FieldVisitor::default(); + attrs.record(&mut visitor); + if let Some(span) = ctx.span(id) { + span.extensions_mut().insert(SpanFields(visitor.fields)); + } + } + + fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) { let mut visitor = FieldVisitor::default(); event.record(&mut visitor); + let mut fields = BTreeMap::new(); + if let Some(scope) = ctx.event_scope(event) { + for span in scope.from_root() { + let extensions = span.extensions(); + if let Some(SpanFields(span_fields)) = extensions.get::() { + for (k, v) in span_fields { + fields.insert(k.clone(), v.clone()); + } + } + } + } + for (k, v) in &visitor.fields { + fields.insert(k.clone(), v.clone()); + } self.events .lock() .expect("capture lock") .push(CapturedEvent { message: visitor.message, - fields: visitor.fields, + fields, + event_fields: visitor.fields, }); } } @@ -92,6 +132,11 @@ impl Layer for CaptureLayer { /// guard. `#[tokio::test]` defaults to a CURRENT-THREAD runtime, so the /// spawned server task (via `tokio::spawn` inside `spawn_server`) is polled /// on this SAME OS thread and observes the thread-local default too. +/// +/// NOTE: events emitted on `spawn_blocking` pool threads (e.g. the +/// registry's `terminal.created`, fired from `handle_create`'s blocking PTY +/// spawn) do NOT observe this thread-local guard -- capture of those goes +/// through the process-`GLOBAL` subscriber below. fn capture() -> ( Arc>>, tracing::subscriber::DefaultGuard, @@ -105,6 +150,37 @@ fn capture() -> ( (events, guard) } +/// Process-global capture for events emitted OFF the test's own thread -- +/// the `spawn_blocking` pool (registry.create's `terminal.created`) has no +/// thread-local dispatcher, so only a global default observes them. Same +/// OnceLock-install semantics as `freshell-freshagent`'s diag01 capture +/// (c62385ab4): first caller installs; `get_or_init` is the synchronization; +/// every later call is a cheap no-op. Events from ALL tests in this binary +/// land in the shared vec, so reads MUST filter by a per-test-unique field +/// (the freshly minted `terminal_id`), never by "ever seen". +static GLOBAL_EVENTS: std::sync::OnceLock>>> = + std::sync::OnceLock::new(); + +fn global_capture() -> (Arc>>, usize) { + let events = GLOBAL_EVENTS + .get_or_init(|| { + let events = Arc::new(Mutex::new(Vec::new())); + let layer = CaptureLayer { + events: Arc::clone(&events), + }; + let subscriber = tracing_subscriber::registry().with(layer); + // This binary installs no other global subscriber; `.expect()` + // turns any future second-installer regression into an immediate + // diagnosable panic instead of a silently-empty capture. + tracing::subscriber::set_global_default(subscriber) + .expect("this test binary installs exactly one global subscriber"); + events + }) + .clone(); + let start_index = events.lock().expect("capture lock").len(); + (events, start_index) +} + // ── server harness (duplicated from keepalive.rs's convention) ──────────── fn test_settings_value() -> serde_json::Value { @@ -142,6 +218,9 @@ async fn spawn_server(ping_interval_ms: u64) -> String { server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: Arc::new(tokio::sync::RwLock::new( + serde_json::from_value(test_settings_value()).expect("valid settings fixture"), + )), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), @@ -335,4 +414,143 @@ async fn diag01_ws_lifecycle_events_fire_with_expected_fields_and_never_leak_the closed.fields.contains_key("connection_id"), "connection.closed must carry connection_id" ); + assert!( + closed.event_fields.contains_key("origin_kind"), + "connection.closed must carry origin_kind AS AN EVENT FIELD (dual-carrier: \ + the span's copy dies along with the span under target-directive-only filters; \ + only the event's own fields survive those)" + ); +} + +/// Proves DIAG-01's "connection ownership" clause holds for events emitted +/// while *serving* a connection: the registry's `terminal.created` (fired +/// from `handle_create`'s `spawn_blocking` PTY spawn on a pool thread) must +/// carry the serving connection's `connection_id` -- via the `ws_conn` span +/// wrapping `run_loop` + the `spawn_blocking_in_span` context hop, observed +/// here exactly the way the production `JsonLayer` flattens span fields +/// into the JSONL line. +#[tokio::test] +async fn diag01_in_connection_events_carry_the_connection_id() { + let (events, start_index) = global_capture(); + let url = spawn_server(30_000).await; + let mut ws = connect_and_complete_handshake(&url).await; + + let request_id = format!("diag01-conn-span-{}", uuid::Uuid::new_v4()); + ws.send(WsMessage::Text( + serde_json::json!({ + "type": "terminal.create", + "requestId": request_id, + "mode": "shell", + "shell": "system", + }) + .to_string(), + )) + .await + .expect("send terminal.create"); + + // Await the terminal.created reply frame and capture the minted id -- the + // airtight filter key for the global event vec below. + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + let mut wire_terminal_id: Option = None; + while tokio::time::Instant::now() < deadline && wire_terminal_id.is_none() { + match tokio::time::timeout(Duration::from_secs(5), ws.next()).await { + Ok(Some(Ok(WsMessage::Text(text)))) => { + let Ok(value) = serde_json::from_str::(&text) else { + continue; + }; + if value.get("type").and_then(|v| v.as_str()) == Some("terminal.created") + && value.get("requestId").and_then(|v| v.as_str()) == Some(request_id.as_str()) + { + wire_terminal_id = value + .get("terminalId") + .and_then(|v| v.as_str()) + .map(str::to_string); + } + } + Ok(Some(Ok(_))) => {} + other => panic!("unexpected frame awaiting terminal.created: {other:?}"), + } + } + let terminal_id = wire_terminal_id.expect("never received terminal.created frame"); + ws.close(None).await.ok(); + + // Poll for OUR captured tracing events to settle (bounded): the created + // event keyed by our terminal_id, then a beat for the close-path events. + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + while tokio::time::Instant::now() < deadline { + let present = { + let captured = events.lock().unwrap(); + captured[start_index..].iter().any(|e| { + e.message == "terminal.created" + && e.fields.get("terminal_id").map(String::as_str) == Some(terminal_id.as_str()) + }) + }; + if present { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + tokio::time::sleep(Duration::from_millis(200)).await; + + let captured = events.lock().unwrap().clone(); + + // Key everything off OUR terminal's uniquely-identified created event + // (other tests in this binary share the global vec; never match on just + // an event name here). + let created = captured[start_index..] + .iter() + .find(|e| { + e.message == "terminal.created" + && e.fields.get("terminal_id").map(String::as_str) == Some(terminal_id.as_str()) + }) + .expect("expected a terminal.created tracing event for our terminal_id"); + let conn_id = created + .fields + .get("connection_id") + .cloned() + .unwrap_or_else(|| { + panic!( + "terminal.created must inherit the serving connection's id via the \ + ws_conn span; got fields: {:?}", + created.fields + ) + }); + + // Coherence: the same connection_id appears on this connection's + // established AND closed lifecycle events, so the whole lifecycle of a + // connection (and the work it performed) is attributable to one id. + for name in ["ws.connection.established", "ws.connection.closed"] { + let matching = captured[start_index..] + .iter() + .any(|e| e.message == name && e.fields.get("connection_id") == Some(&conn_id)); + assert!( + matching, + "expected a {name} event with connection_id {conn_id}" + ); + } + + // The ws-side settle companion carries the SAME join as explicit EVENT + // fields (not span context) -- the dual-carrier design (review round 2): + // span enrichment dies under tracing-subscriber's target-directive-only + // filters (empirically, even a matched `freshell_ws=info` directive + // disables span callsites), but an event's own fields ride through any + // filter that admits the event. `ws.terminal.create.settled` is the + // connection_id <-> terminal_id <-> requestId join that survives. + let settled = captured[start_index..] + .iter() + .find(|e| { + e.message == "ws.terminal.create.settled" + && e.fields.get("terminal_id").map(String::as_str) == Some(terminal_id.as_str()) + }) + .expect("expected a ws.terminal.create.settled companion event for our terminal_id"); + assert_eq!( + settled.fields.get("connection_id").map(String::as_str), + Some(conn_id.as_str()), + "settle companion carries connection_id as an event field" + ); + assert_eq!( + settled.fields.get("request_id").map(String::as_str), + Some(request_id.as_str()), + "settle companion carries the client requestId as an event field" + ); } diff --git a/crates/freshell-ws/tests/freshagent_claude_attach.rs b/crates/freshell-ws/tests/freshagent_claude_attach.rs index eb383030e..127c8e7d5 100644 --- a/crates/freshell-ws/tests/freshagent_claude_attach.rs +++ b/crates/freshell-ws/tests/freshagent_claude_attach.rs @@ -180,6 +180,9 @@ async fn spawn_server() -> String { server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: Arc::new(tokio::sync::RwLock::new( + serde_json::from_value(test_settings_value()).expect("valid settings fixture"), + )), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs b/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs index 66d8bb8df..d77693082 100644 --- a/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs +++ b/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs @@ -177,6 +177,9 @@ async fn spawn_server() -> String { server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: Arc::new(tokio::sync::RwLock::new( + serde_json::from_value(test_settings_value()).expect("valid settings fixture"), + )), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/freshagent_session_lease.rs b/crates/freshell-ws/tests/freshagent_session_lease.rs index c61c3e8d0..e822ca137 100644 --- a/crates/freshell-ws/tests/freshagent_session_lease.rs +++ b/crates/freshell-ws/tests/freshagent_session_lease.rs @@ -202,6 +202,9 @@ async fn spawn_server() -> String { server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: Arc::new(tokio::sync::RwLock::new( + serde_json::from_value(test_settings_value()).expect("valid settings fixture"), + )), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/handshake_live_settings.rs b/crates/freshell-ws/tests/handshake_live_settings.rs new file mode 100644 index 000000000..53620c611 --- /dev/null +++ b/crates/freshell-ws/tests/handshake_live_settings.rs @@ -0,0 +1,83 @@ +//! CFG-12 server-surface proof: a real `/ws` connect handshake resolves the +//! LIVE server-settings tree PER CONNECTION (legacy parity: +//! `server/index.ts:415-427`'s `handshakeSnapshotProvider` awaits +//! `configStore.getSettings()` on every hello; `ws-handler.ts:1815-1845` +//! sends that tree as `settings.updated`). +//! +//! Before CFG-12 the port emitted a boot-frozen `WsState.settings` snapshot in +//! every handshake, so a `PATCH /api/settings`-committed value (e.g. +//! `defaultCwd`) never reached a client that (re)connected after the patch -- +//! and the client's last-write-wins application of the handshake frame erased +//! the correct value `/api/bootstrap` had already delivered (the e2e red at +//! `settings-persistence-split.spec.ts`'s defaultCwd leg). + +mod common; + +use std::time::Duration; + +use futures_util::SinkExt; +use futures_util::StreamExt; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +/// Connect + hello, then scan the ordered handshake frames for +/// `settings.updated` (bounded; the clean handshake is 4 frames: +/// ready -> settings.updated -> perf.logging -> terminal.inventory). +async fn connect_and_capture_settings_updated(url: &str) -> (common::TestWs, serde_json::Value) { + let (mut ws, _resp) = tokio_tungstenite::connect_async(url) + .await + .expect("ws connect"); + ws.send(WsMessage::Text( + serde_json::json!({ + "type": "hello", + "token": common::AUTH_TOKEN, + "protocolVersion": freshell_protocol::WS_PROTOCOL_VERSION, + }) + .to_string(), + )) + .await + .expect("send hello"); + + for _ in 0..8u8 { + let msg = tokio::time::timeout(Duration::from_secs(5), ws.next()) + .await + .expect("handshake message within timeout") + .expect("stream not ended") + .expect("no ws error"); + if let WsMessage::Text(text) = &msg { + let value: serde_json::Value = serde_json::from_str(text).expect("json frame"); + if value["type"] == serde_json::json!("settings.updated") { + return (ws, value); + } + } + } + panic!("handshake must contain settings.updated"); +} + +#[tokio::test] +async fn second_connection_handshake_carries_settings_written_after_first_connection() { + let (url, _registry, live_settings) = + common::spawn_server_with_specs_and_shared_settings(vec![]).await; + + // Connection 1 resolves the tree as of its hello: no `defaultCwd` yet + // (the shared fixture tree has none). + let (ws1, first) = connect_and_capture_settings_updated(&url).await; + assert!( + first["settings"].get("defaultCwd").is_none(), + "pre-write handshake must not invent a defaultCwd: {first}" + ); + + // The PATCH-committed write lands in the SAME live tree the handshake + // resolves (freshell-server wires `SettingsStore::shared_settings_lock()` + // in here; one lock, no copies, no caching layer). + live_settings.write().await.default_cwd = Some("/tmp/cfg12-live".to_string()); + + // Connection 2 (a reload/reconnect) resolves the LIVE tree. + let (_ws2, second) = connect_and_capture_settings_updated(&url).await; + assert_eq!( + second["settings"]["defaultCwd"], + serde_json::json!("/tmp/cfg12-live"), + "a later connection's settings.updated must carry the live tree: {second}" + ); + + drop(ws1); +} diff --git a/crates/freshell-ws/tests/hello_timeout.rs b/crates/freshell-ws/tests/hello_timeout.rs index 88db62d4f..78e283c37 100644 --- a/crates/freshell-ws/tests/hello_timeout.rs +++ b/crates/freshell-ws/tests/hello_timeout.rs @@ -62,6 +62,9 @@ async fn spawn_server(hello_timeout_ms: u64) -> String { server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: Arc::new(tokio::sync::RwLock::new( + serde_json::from_value(test_settings_value()).expect("valid settings fixture"), + )), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/keepalive.rs b/crates/freshell-ws/tests/keepalive.rs index 027faa945..54f8fa2a5 100644 --- a/crates/freshell-ws/tests/keepalive.rs +++ b/crates/freshell-ws/tests/keepalive.rs @@ -63,6 +63,9 @@ async fn spawn_server( server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: Arc::new(tokio::sync::RwLock::new( + serde_json::from_value(test_settings_value()).expect("valid settings fixture"), + )), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/max_payload.rs b/crates/freshell-ws/tests/max_payload.rs index f5378af07..57f1e5b9c 100644 --- a/crates/freshell-ws/tests/max_payload.rs +++ b/crates/freshell-ws/tests/max_payload.rs @@ -63,6 +63,9 @@ async fn spawn_server(ws_max_payload_bytes: usize) -> String { server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: Arc::new(tokio::sync::RwLock::new( + serde_json::from_value(test_settings_value()).expect("valid settings fixture"), + )), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/opencode_switch_rebind.rs b/crates/freshell-ws/tests/opencode_switch_rebind.rs index 1787b13a2..1b8da1832 100644 --- a/crates/freshell-ws/tests/opencode_switch_rebind.rs +++ b/crates/freshell-ws/tests/opencode_switch_rebind.rs @@ -220,6 +220,7 @@ async fn spawn_server_returning_state( server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: common::handshake_settings_lock(), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/origin_policy.rs b/crates/freshell-ws/tests/origin_policy.rs index cda36b162..95d507e16 100644 --- a/crates/freshell-ws/tests/origin_policy.rs +++ b/crates/freshell-ws/tests/origin_policy.rs @@ -53,6 +53,9 @@ async fn spawn_server(allowed_origins: Vec) -> (String, String) { server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: Arc::new(tokio::sync::RwLock::new( + serde_json::from_value(test_settings_value()).expect("valid settings fixture"), + )), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/pane_reconcile.rs b/crates/freshell-ws/tests/pane_reconcile.rs index bbce6f5c8..a7d2ed5dc 100644 --- a/crates/freshell-ws/tests/pane_reconcile.rs +++ b/crates/freshell-ws/tests/pane_reconcile.rs @@ -131,6 +131,9 @@ async fn spawn_server_with_probe( server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: Arc::new(tokio::sync::RwLock::new( + serde_json::from_value(test_settings_value()).expect("valid settings fixture"), + )), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/pane_reconcile_freshagent.rs b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs index 5be993863..441ec98ce 100644 --- a/crates/freshell-ws/tests/pane_reconcile_freshagent.rs +++ b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs @@ -205,6 +205,9 @@ async fn spawn_server_with_probe(probe: Arc) -> Server { server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: Arc::new(tokio::sync::RwLock::new( + serde_json::from_value(test_settings_value()).expect("valid settings fixture"), + )), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/rest_claude_identity.rs b/crates/freshell-ws/tests/rest_claude_identity.rs index a83a098dc..cd9d4c0b3 100644 --- a/crates/freshell-ws/tests/rest_claude_identity.rs +++ b/crates/freshell-ws/tests/rest_claude_identity.rs @@ -74,6 +74,7 @@ async fn spawn_merged_server() -> Harness { server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: common::handshake_settings_lock(), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/rest_locator_identity.rs b/crates/freshell-ws/tests/rest_locator_identity.rs index 4860da8c9..b46a5a656 100644 --- a/crates/freshell-ws/tests/rest_locator_identity.rs +++ b/crates/freshell-ws/tests/rest_locator_identity.rs @@ -98,6 +98,7 @@ async fn spawn_merged_server( server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: common::handshake_settings_lock(), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_cancels: Default::default(), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, diff --git a/crates/freshell-ws/tests/rest_ws_shared_gate.rs b/crates/freshell-ws/tests/rest_ws_shared_gate.rs index 0c0143d6b..5cd52cedd 100644 --- a/crates/freshell-ws/tests/rest_ws_shared_gate.rs +++ b/crates/freshell-ws/tests/rest_ws_shared_gate.rs @@ -134,6 +134,7 @@ async fn spawn_combined_server( server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: common::handshake_settings_lock(), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/restore_plan_queue_cap.rs b/crates/freshell-ws/tests/restore_plan_queue_cap.rs index fe981e61e..39ac14f61 100644 --- a/crates/freshell-ws/tests/restore_plan_queue_cap.rs +++ b/crates/freshell-ws/tests/restore_plan_queue_cap.rs @@ -106,6 +106,9 @@ async fn spawn_server( server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: Arc::new(tokio::sync::RwLock::new( + serde_json::from_value(test_settings_value()).expect("valid settings fixture"), + )), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/restore_spawn_gate.rs b/crates/freshell-ws/tests/restore_spawn_gate.rs index 4a8295eec..1234efd39 100644 --- a/crates/freshell-ws/tests/restore_spawn_gate.rs +++ b/crates/freshell-ws/tests/restore_spawn_gate.rs @@ -102,6 +102,9 @@ async fn spawn_server( server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: Arc::new(tokio::sync::RwLock::new( + serde_json::from_value(test_settings_value()).expect("valid settings fixture"), + )), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/restore_storm.rs b/crates/freshell-ws/tests/restore_storm.rs index 91eba0faf..083bff8dd 100644 --- a/crates/freshell-ws/tests/restore_storm.rs +++ b/crates/freshell-ws/tests/restore_storm.rs @@ -112,6 +112,9 @@ async fn spawn_server( server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: Arc::new(tokio::sync::RwLock::new( + serde_json::from_value(test_settings_value()).expect("valid settings fixture"), + )), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/resume_validation_gate.rs b/crates/freshell-ws/tests/resume_validation_gate.rs index 9161b33fe..66598da78 100644 --- a/crates/freshell-ws/tests/resume_validation_gate.rs +++ b/crates/freshell-ws/tests/resume_validation_gate.rs @@ -147,6 +147,7 @@ async fn spawn_server_with_probe( server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: common::handshake_settings_lock(), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), @@ -320,6 +321,7 @@ async fn spawn_managed_codex_server_with_probe( server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: common::handshake_settings_lock(), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/safe08_restore_diagnostics.rs b/crates/freshell-ws/tests/safe08_restore_diagnostics.rs index c561e4b3f..370c42780 100644 --- a/crates/freshell-ws/tests/safe08_restore_diagnostics.rs +++ b/crates/freshell-ws/tests/safe08_restore_diagnostics.rs @@ -149,6 +149,9 @@ async fn spawn_server() -> String { server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: Arc::new(tokio::sync::RwLock::new( + serde_json::from_value(test_settings_value()).expect("valid settings fixture"), + )), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/session_ref_singleflight.rs b/crates/freshell-ws/tests/session_ref_singleflight.rs index d52ea6b4b..b35deb8f9 100644 --- a/crates/freshell-ws/tests/session_ref_singleflight.rs +++ b/crates/freshell-ws/tests/session_ref_singleflight.rs @@ -184,6 +184,92 @@ async fn two_clients_same_session_ref_yield_exactly_one_pty() { registry.kill_all(); } +/// df1 wrap-review r2 pin: a sessionRef ADOPTION is a successful create — +/// it must SETTLE the server-wide `create_dedupe` entry so a later +/// same-requestId resend replays the settled frame instead of re-entering +/// `handle_create`. Before the fix, the `session_ref_attached` early +/// return skipped `create_dedupe.settle`: the caller's +/// `clear_if_in_flight` dropped the still-InFlight sentinel (erroring any +/// cross-connection waiters with PTY_SPAWN_FAILED) and — worst case — a +/// blind same-requestId resend on a NON-negotiated (frozen) connection +/// re-entered `handle_create` with `pane_reconcile_v1 == false` and +/// SPAWNED A DUPLICATE PTY. (The §5.4 keyed adopt early return shares the +/// identical settle discipline — its remaining seed is REST-stamped +/// registry rows, and both returns were fixed together.) The sequence +/// here is fully serialized — winner awaited before the attacher sends — +/// so no reservation race is involved. +#[tokio::test] +async fn session_ref_adoption_settles_dedupe_for_later_legacy_resends() { + const SESS_SETTLE: &str = "22222222-2222-4222-8222-222222222222"; + let (url, registry) = spawn_server_with_specs(vec![sleeper_cli_spec("claude")]).await; + + // The winner spawns the session's PTY under requestId "wr-win". + let mut winner = connect(&url, true).await; + send_json( + &mut winner, + terminal_create_resume("wr-win", "claude", SESS_SETTLE), + ) + .await; + let created = next_created_or_error(&mut winner, "wr-win").await; + assert_eq!( + created["type"], + serde_json::json!("terminal.created"), + "{created}" + ); + let tid = created["terminalId"] + .as_str() + .expect("terminalId") + .to_string(); + + // The attacher: SAME sessionRef, fresh requestId "wr-attach" — the + // claim reports BoundElsewhere and the attach path names the winner's + // terminal (no second spawn). + let mut attacher = connect(&url, true).await; + send_json( + &mut attacher, + terminal_create_resume("wr-attach", "claude", SESS_SETTLE), + ) + .await; + let attached = next_created_or_error(&mut attacher, "wr-attach").await; + assert_eq!( + attached["type"], + serde_json::json!("terminal.created"), + "{attached}" + ); + assert_eq!(attached["terminalId"], serde_json::json!(tid.clone())); + + // Post-fix, "wr-attach" is SETTLED to the winner's terminal: the frozen + // client's blind same-requestId resend on reconnect replays the frame. + let mut legacy = connect(&url, false).await; + send_json( + &mut legacy, + terminal_create_resume("wr-attach", "claude", SESS_SETTLE), + ) + .await; + let replayed = next_created_or_error(&mut legacy, "wr-attach").await; + assert_eq!( + replayed["type"], + serde_json::json!("terminal.created"), + "a settled adoption must replay its terminal.created, not error: {replayed}" + ); + assert_eq!( + replayed["terminalId"], + serde_json::json!(tid.clone()), + "the legacy resend must name the adopted terminal, never a fresh PTY" + ); + + assert_eq!( + live_pty_count_for_session(®istry, "claude", SESS_SETTLE), + 1, + "exactly one live PTY for the session across winner + attach + resend" + ); + assert_eq!( + registry.kill_all(), + 1, + "no duplicate spawn anywhere in the flow" + ); +} + /// Legacy connections (no capability) never see SESSION_RESERVED — the /// frozen-client create path is byte-for-byte unchanged. #[tokio::test] diff --git a/crates/freshell-ws/tests/term09_output_queue.rs b/crates/freshell-ws/tests/term09_output_queue.rs index f83b3e162..dc429ed74 100644 --- a/crates/freshell-ws/tests/term09_output_queue.rs +++ b/crates/freshell-ws/tests/term09_output_queue.rs @@ -56,6 +56,9 @@ async fn spawn_server(term09: Term09Config) -> String { server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: Arc::new(tokio::sync::RwLock::new( + serde_json::from_value(test_settings_value()).expect("valid settings fixture"), + )), broadcast_tx: Arc::clone(&broadcast_tx), auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, auto_resume_cancels: Default::default(), diff --git a/crates/freshell-ws/tests/test_clock_routing.rs b/crates/freshell-ws/tests/test_clock_routing.rs new file mode 100644 index 000000000..49b10ec8c --- /dev/null +++ b/crates/freshell-ws/tests/test_clock_routing.rs @@ -0,0 +1,121 @@ +//! HARNESS-14 — routing proofs for the `freshell-ws` seams, run as an +//! INTEGRATION binary (its own process) on purpose: the shared test clock is +//! process-global, so overriding it inside the crate's unit-test binary +//! pollutes parallel siblings (proven RED against the pre-existing +//! `devicecount_excludes_devices...` TTL test before this split). +//! +//! Proves: +//! 1. the 7-day device-display TTL (`tabs.rs` `diagnostic_counts`) follows +//! virtual `advance_ms()` — a device pushed BEFORE a virtual 8-day step +//! expires; one pushed AFTER (same real instant) survives; +//! 2. the terminal.create rate window (`create_limit.rs` `epoch_ms()`) +//! never drains on real time while frozen, and frees instantly on one +//! virtual step past the window. +//! +//! Zero wall-clock sleeps for the virtual waits. + +use std::sync::{Mutex, MutexGuard}; + +use freshell_ws::create_limit::{epoch_ms, CreateRateLimiter}; +use freshell_ws::tabs::TabsRegistry; +use serde_json::{json, Value}; + +const DAY_MS: i64 = 24 * 60 * 60 * 1000; + +/// Serialize + scope the process-global override within THIS binary. +static LOCK: Mutex<()> = Mutex::new(()); + +struct GateGuard { + _guard: MutexGuard<'static, ()>, +} + +impl GateGuard { + fn enable() -> Self { + let guard = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + freshell_platform::clock::set_enabled_override_for_tests(Some(true)); + freshell_platform::clock::reset().expect("override enabled"); + Self { _guard: guard } + } +} + +impl Drop for GateGuard { + fn drop(&mut self) { + let _ = freshell_platform::clock::reset(); + freshell_platform::clock::set_enabled_override_for_tests(None); + } +} + +fn open_record(tab_key: &str, tab_name: &str, updated_at: i64) -> Value { + // Same envelope shape as the in-crate tests' helper. + json!({ + "tabKey": tab_key, + "tabId": tab_key, + "tabName": tab_name, + "status": "open", + "revision": 1, + "updatedAt": updated_at, + "createdAt": updated_at, + "paneCount": 1, + "titleSetByUser": true, + "panes": [], + }) +} + +#[test] +fn device_display_ttl_follows_the_shared_test_clock() { + let _gate = GateGuard::enable(); + let reg = TabsRegistry::new(); + + freshell_platform::clock::freeze().unwrap(); + reg.replace_client_snapshot( + "srv-1", + "device-old", + "Old Device", + "client-1", + 1, + vec![open_record("t-old", "old tab", 1)], + ) + .expect("push accepted"); + + // Eight virtual days pass with no real elapsed time... + freshell_platform::clock::advance_ms(8 * DAY_MS).unwrap(); + + // ...then a second device registers at the NEW virtual now. + reg.replace_client_snapshot( + "srv-1", + "device-new", + "New Device", + "client-2", + 1, + vec![open_record("t-new", "new tab", 1)], + ) + .expect("push accepted"); + + let (_record_count, device_count) = reg.diagnostic_counts(); + assert_eq!( + device_count, 1, + "after a virtual 8-day step, only the post-step device survives the 7-day TTL" + ); +} + +#[test] +fn create_rate_window_follows_the_shared_test_clock() { + let _gate = GateGuard::enable(); + freshell_platform::clock::freeze().unwrap(); + + let mut l = CreateRateLimiter::new(1, 10_000); + assert!(l.try_acquire(epoch_ms())); + assert!( + !l.try_acquire(epoch_ms()), + "frozen time: the second acquire is inside the window forever" + ); + // Real elapsed time inside the window must not drain it (frozen). + std::thread::sleep(std::time::Duration::from_millis(20)); + assert!(!l.try_acquire(epoch_ms()), "still frozen — no drain"); + + freshell_platform::clock::advance_ms(10_001).unwrap(); + assert!( + l.try_acquire(epoch_ms()), + "a virtual step past the window must free the slot" + ); +} diff --git a/crates/freshell-ws/tests/ui_layout_sync.rs b/crates/freshell-ws/tests/ui_layout_sync.rs index e39a599af..9d4bc30f0 100644 --- a/crates/freshell-ws/tests/ui_layout_sync.rs +++ b/crates/freshell-ws/tests/ui_layout_sync.rs @@ -84,6 +84,9 @@ async fn spawn_server() -> (String, String, LayoutStore) { server_instance_id: Arc::new("srv-test".to_string()), boot_id: Arc::new("boot-test".to_string()), settings, + handshake_settings: Arc::new(tokio::sync::RwLock::new( + serde_json::from_value(test_settings_value()).expect("valid settings fixture"), + )), broadcast_tx: Arc::clone(&broadcast_tx), fresh_codex: freshell_freshagent::FreshCodexState::new( Arc::clone(&auth_token), @@ -383,3 +386,289 @@ async fn two_client_syncs_coexist_rest_resolves_non_primary_ids_and_disconnect_r let (tabs, _) = layout.list_tabs(); assert_eq!(tabs[0]["id"], serde_json::json!("t1")); } +// -- Ported regression pins from the df1 AUTO-01 work, run against the +// merged (main-side evolved) implementation. Spliced after main-side tests +// by the df1 main-sync merge (PR #638 advance). + +mod common; + +use common::TestWs as CommonTestWs; +use common::{connect_and_capture_inventory, spawn_server_with_specs_and_state}; +use serde_json::json; + +fn layout_sync_frame(tabs: serde_json::Value, layouts: serde_json::Value) -> serde_json::Value { + let active_tab_id = tabs[0]["id"].clone(); + json!({ + "type": "ui.layout.sync", + "tabs": tabs, + "activeTabId": active_tab_id, + "layouts": layouts, + "activePane": { active_tab_id.as_str().unwrap_or(""): "pane_1" }, + "paneTitles": {}, + "paneTitleSetByUser": {}, + "timestamp": 1_720_000_000_000_i64, + }) +} + +async fn send_json(ws: &mut CommonTestWs, frame: serde_json::Value) { + ws.send(WsMessage::Text(frame.to_string())) + .await + .expect("send"); +} + +#[tokio::test] +async fn ui_layout_sync_updates_the_shared_layout_store() { + let (url, _registry, state) = spawn_server_with_specs_and_state(vec![]).await; + let (mut ws, _inventory) = connect_and_capture_inventory(&url).await; + + // Frame from the REAL client middleware shape: nested split with a legacy + // `agent-chat` leaf that the store must normalize on ingest. + send_json( + &mut ws, + layout_sync_frame( + json!([{ "id": "tab_r", "title": "Remote" }]), + json!({ + "tab_r": { + "type": "split", + "id": "split_1", + "direction": "horizontal", + "sizes": [60, 40], + "children": [ + { + "type": "leaf", + "id": "pane_1", + "content": { + "kind": "agent-chat", + "provider": "claude", + "createRequestId": "req-1", + "status": "idle", + "resumeSessionId": "11111111-1111-4111-8111-111111111111", + }, + }, + { + "type": "leaf", + "id": "pane_2", + "content": { "kind": "terminal", "terminalId": "term_2", "mode": "shell" }, + }, + ], + } + }), + ), + ) + .await; + + // The ingest is synchronous on the read loop; send a ping so its `pong` + // proves the sync frame was processed before we read the store. + send_json(&mut ws, json!({ "type": "ping" })).await; + let _pong = common::next_frame_of_type(&mut ws, "pong").await; + + let store = state.layout.clone(); + let snap = store.get_normalized_snapshot(None); + assert_eq!(snap["tabs"], json!([{ "id": "tab_r", "title": "Remote" }])); + assert_eq!(snap["activeTabId"], json!("tab_r")); + let tree = &snap["layouts"]["tab_r"]; + assert_eq!(tree["type"], json!("split")); + assert_eq!(tree["sizes"], json!([60, 40])); + assert!(serde_json::to_string(tree) + .expect("serialize") + .contains("\"fresh-agent\"")); + assert!(!serde_json::to_string(tree) + .expect("serialize") + .contains("\"agent-chat\"")); + assert_eq!( + tree["children"][0]["content"]["sessionRef"], + json!({ "provider": "claude", "sessionId": "11111111-1111-4111-8111-111111111111" }) + ); + // Derived titles seeded on ingest ("Shell" for the modeless terminal). + assert_eq!(snap["paneTitles"]["tab_r"]["pane_2"], json!("Shell")); + assert_eq!(snap["timestamp"], json!(1_720_000_000_000_i64)); + assert!(store.source_connection_id().is_some()); +} + +#[tokio::test] +async fn ui_layout_sync_last_write_wins_across_connections() { + let (url, _registry, state) = spawn_server_with_specs_and_state(vec![]).await; + let (mut ws_a, _i1) = connect_and_capture_inventory(&url).await; + let (mut ws_b, _i2) = connect_and_capture_inventory(&url).await; + + send_json( + &mut ws_a, + layout_sync_frame( + json!([{ "id": "tab_from_a", "title": "A" }]), + json!({ "tab_from_a": { "type": "leaf", "id": "pane_1", "content": { "kind": "terminal" } } }), + ), + ) + .await; + send_json(&mut ws_a, json!({ "type": "ping" })).await; + let _ = common::next_frame_of_type(&mut ws_a, "pong").await; + let store = state.layout.clone(); + assert_eq!( + store.get_normalized_snapshot(None)["activeTabId"], + json!("tab_from_a") + ); + let source_after_a = store.source_connection_id().expect("source recorded"); + + send_json( + &mut ws_b, + layout_sync_frame( + json!([{ "id": "tab_from_b", "title": "B" }]), + json!({ "tab_from_b": { "type": "leaf", "id": "pane_1", "content": { "kind": "browser", "url": "https://docs.example.com/x", "devToolsOpen": false } } }), + ), + ) + .await; + send_json(&mut ws_b, json!({ "type": "ping" })).await; + let _ = common::next_frame_of_type(&mut ws_b, "pong").await; + + // Legacy semantics: the second client's mirror REPLACES the whole + // snapshot; the winning connection is recorded (AUTO-14's substrate). + let snap = store.get_normalized_snapshot(None); + assert_eq!(snap["activeTabId"], json!("tab_from_b")); + assert!(snap["layouts"].get("tab_from_a").is_none()); + assert_eq!( + snap["paneTitles"]["tab_from_b"]["pane_1"], + json!("docs.example.com") + ); + let source_after_b = store.source_connection_id().expect("source recorded"); + assert_ne!(source_after_a, source_after_b); +} + +#[tokio::test] +async fn ui_layout_sync_ingest_never_replies() { + let (url, _registry, _state) = spawn_server_with_specs_and_state(vec![]).await; + let (mut ws, _inventory) = connect_and_capture_inventory(&url).await; + send_json( + &mut ws, + layout_sync_frame( + json!([{ "id": "tab_q", "title": "Q" }]), + json!({ "tab_q": { "type": "leaf", "id": "pane_1", "content": { "kind": "terminal" } } }), + ), + ) + .await; + // No frame may arrive until we provoke one (ping -> pong): legacy's + // ui.layout.sync case `return`s without sending anything. The VERY NEXT + // frame must be the pong — reading raw (not `next_frame_of_type`, which + // would hide an interleaved ack/error) is what proves silence. + send_json(&mut ws, json!({ "type": "ping" })).await; + let msg = tokio::time::timeout(Duration::from_secs(5), ws.next()) + .await + .expect("next frame within timeout") + .expect("stream not ended") + .expect("no ws error"); + let WsMessage::Text(text) = msg else { + panic!("expected the pong TEXT frame as the very next frame, got {msg:?}") + }; + let value: serde_json::Value = serde_json::from_str(&text).expect("json frame"); + assert_eq!( + value["type"], + json!("pong"), + "the first post-sync frame must be the pong (any ack/error would arrive first)" + ); +} + +#[tokio::test] +async fn ui_layout_sync_is_served_back_through_rest_on_the_same_process() { + let (url, _registry, state) = spawn_server_with_specs_and_state(vec![]).await; + // Mount the fresh-agent REST router against a FreshAgentState sharing the + // SAME layout store the WS dispatch feeds — the exact wiring + // freshell-server's main.rs production composition has (one store per + // process, threaded via `.with_layout(...)`). NOTE: `WsState::state()` + // wires `layout: Default::default()` separately from the fresh-agent + // state's own store, so mounting `state.fresh_opencode.fresh_agent()` + // here would read a DIFFERENT store and this endpoint would answer empty. + let rest_state = freshell_freshagent::FreshAgentState::new( + state.auth_token.clone(), + state.broadcast_tx.clone(), + ) + .with_layout(state.layout.clone()); + let rest_router = freshell_freshagent::router(rest_state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral loopback port"); + let addr = listener.local_addr().expect("local addr"); + tokio::spawn(async move { + let _ = axum::serve(listener, rest_router).await; + }); + + let (mut ws, _inventory) = connect_and_capture_inventory(&url).await; + send_json( + &mut ws, + layout_sync_frame( + json!([{ "id": "tab_ws", "title": "WS-fed tab" }]), + json!({ + "tab_ws": { + "type": "split", + "id": "split_ws", + "direction": "horizontal", + "sizes": [33, 67], + "children": [ + { "type": "leaf", "id": "pane_1", "content": { "kind": "terminal", "terminalId": "term_ws", "mode": "shell" } }, + { "type": "leaf", "id": "pane_2", "content": { "kind": "editor", "filePath": "/tmp/ws.md" } }, + ], + } + }), + ), + ) + .await; + send_json(&mut ws, json!({ "type": "ping" })).await; + let _ = common::next_frame_of_type(&mut ws, "pong").await; + + // The authoritative layout is now observable over REST — browser, CLI, + // and MCP all read THIS (AUTO-01's whole point). + let client = reqwest::Client::new(); + let resp = client + .get(format!("http://{addr}/api/layout/snapshot")) + .header("x-auth-token", common::AUTH_TOKEN) + .send() + .await + .expect("GET /api/layout/snapshot"); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = + serde_json::from_str(&resp.text().await.expect("body text")).expect("json body"); + let data = &body["data"]; + assert_eq!( + data["tabs"], + json!([{ "id": "tab_ws", "title": "WS-fed tab" }]) + ); + assert_eq!(data["activeTabId"], json!("tab_ws")); + let tree = &data["layouts"]["tab_ws"]; + assert_eq!(tree["type"], json!("split")); + assert_eq!(tree["id"], json!("split_ws")); + assert_eq!(tree["sizes"], json!([33, 67])); + assert_eq!(data["activePane"]["tab_ws"], json!("pane_1")); + assert_eq!(data["paneTitles"]["tab_ws"]["pane_1"], json!("Shell")); + assert_eq!(data["paneTitles"]["tab_ws"]["pane_2"], json!("ws.md")); + + let resp = client + .get(format!("http://{addr}/api/panes?tabId=tab_ws")) + .header("x-auth-token", common::AUTH_TOKEN) + .send() + .await + .expect("GET /api/panes"); + let body: serde_json::Value = + serde_json::from_str(&resp.text().await.expect("body text")).expect("json body"); + assert_eq!( + body["data"]["panes"], + // Legacy-exact rows: absent fields are OMITTED (never null keys), and + // the row shape carries NO tabId (`listPanes`, layout-store.ts:341-355). + json!([ + { "id": "pane_1", "index": 0, "kind": "terminal", "terminalId": "term_ws", "title": "Shell" }, + { "id": "pane_2", "index": 1, "kind": "editor", "title": "ws.md" }, + ]) + ); + + let resp = client + .get(format!("http://{addr}/api/tabs")) + .header("x-auth-token", common::AUTH_TOKEN) + .send() + .await + .expect("GET /api/tabs"); + let body: serde_json::Value = + serde_json::from_str(&resp.text().await.expect("body text")).expect("json body"); + assert_eq!( + body["data"], + json!({ + "tabs": [{ "id": "tab_ws", "title": "WS-fed tab", "activePaneId": "pane_1" }], + "activeTabId": "tab_ws", + }) + ); +} diff --git a/docker/cloud-run/entrypoint.sh b/docker/cloud-run/entrypoint.sh index 7bbab3e7f..86b371a37 100755 --- a/docker/cloud-run/entrypoint.sh +++ b/docker/cloud-run/entrypoint.sh @@ -20,8 +20,10 @@ # # Args can be passed two ways: # 1. As container args (docker run ... --project=chromium auth.spec.ts) -# 2. Via PLAYWRIGHT_ARGS env var (space-separated, for Cloud Run Jobs where -# --args uses comma separators that conflict with Playwright arg values) +# 2. Via PLAYWRIGHT_ARGS env var (NEWLINE-separated, one arg per line — for +# Cloud Run Jobs where --args uses comma separators that conflict with +# Playwright arg values; newline-delimited so args containing spaces, +# e.g. --grep "foo bar", survive verbatim) # # If both are present, they are combined. # @@ -75,17 +77,19 @@ DRY_RUN=false FLAGS=() SPEC_FILTERS=() -# From PLAYWRIGHT_ARGS env (space-separated). +# From PLAYWRIGHT_ARGS env (newline-separated — one arg per line, so args +# containing spaces are never re-split; see e2e-cloud.sh's --env-vars-file +# emission). if [ -n "${PLAYWRIGHT_ARGS:-}" ]; then - # shellcheck disable=SC2206 - for arg in $PLAYWRIGHT_ARGS; do + while IFS= read -r arg; do + [ -z "$arg" ] && continue case "$arg" in --dry-run) DRY_RUN=true ;; --shard=*) ;; # strip stale shard flags (entrypoint handles sharding) -*) FLAGS+=("$arg") ;; *) SPEC_FILTERS+=("$arg") ;; esac - done + done <<< "$PLAYWRIGHT_ARGS" fi # From container args ($@). diff --git a/docs/plans/2026-08-09-cloud-run-jobs.md b/docs/plans/2026-08-09-cloud-run-jobs.md index e5468a310..6c8b3e928 100644 --- a/docs/plans/2026-08-09-cloud-run-jobs.md +++ b/docs/plans/2026-08-09-cloud-run-jobs.md @@ -10,6 +10,27 @@ **Tech Stack:** Docker multi-stage build (`rust:1-bookworm` + `node:22-bookworm`), Google Cloud Run Jobs, gcloud CLI, Playwright 1.58.2, bash wrapper script. +> **Shipped deviation (wrap-review correction):** the Goal/Architecture text +> above and R1 below describe the original REQUEST — "cloud by default." +> What actually shipped keeps **local as the unset-default**: `npm run +> test:e2e` resolves `"${FRESHELL_E2E_BACKEND:-local}"` in +> `scripts/e2e-cloud.sh`, so a fresh clone runs locally; cloud is opt-in via +> `FRESHELL_E2E_BACKEND=cloud`, the `--cloud` flag, or +> `npm run test:e2e:cloud`. Pinned by checks 9-11 of +> `scripts/test/cloud-run-wrapper.test.sh`. (The squash commit `ab8d6ed46`'s +> "make cloud the default" line likewise describes the request, not the +> shipped behavior; commit messages are immutable history and are not +> rewritten.) + +> **Executed-plan scope note:** the per-task red/green steps below are the +> campaign's execution HISTORY, not steps re-runnable at the merged HEAD — +> each "verify the intended failure" step expected failure only at that +> point in the execution (after the task's test landed, before its +> implementation did). Re-running those steps against HEAD succeeds, which +> is the expected outcome of a completed plan. The section meant to be +> re-executed today is the validation runbook near the end (updated in +> wrap-review r4 to select cloud explicitly). + ## Global Constraints - GCP account: `dan@danshapiro.com`, project: `misc-puttering-project`, region: `us-west1` @@ -26,7 +47,7 @@ ## Requirements -- **R1 — Cloud default:** `npm run test:e2e` executes the Playwright e2e suite on Google Cloud Run Jobs by default, not locally. +- **R1 — Cloud default (NOT SHIPPED as written — see the Shipped-deviation note above):** `npm run test:e2e` executes the Playwright e2e suite on Google Cloud Run Jobs by default, not locally. **Shipped behavior:** unset `FRESHELL_E2E_BACKEND` resolves to **local** (`scripts/e2e-cloud.sh` `"${FRESHELL_E2E_BACKEND:-local}"`, pinned by `scripts/test/cloud-run-wrapper.test.sh` checks 9-11); cloud runs only via `FRESHELL_E2E_BACKEND=cloud`, the `--cloud` flag, or `npm run test:e2e:cloud`. Any agent executing steps below must verify against the SHIPPED contract, not this bullet. - **R2 — Local fallback:** `npm run test:e2e:local` (and `npm run test:e2e -- --local`) runs the same suite locally via direct Playwright invocation, preserving the pre-change behavior. - **R3 — End-to-end validation:** A Cloud Run Job executes a real test run (at minimum `auth.spec.ts`) and returns passing results that match the local baseline. - **R4 — Pass-through args:** The cloud execution path supports `--grep`, `--project`, and spec-file filter arguments passed through to Playwright inside the container. @@ -299,33 +320,33 @@ git commit -m "feat: add e2e-cloud wrapper script, make cloud the default with - - No new files. Uses `scripts/e2e-cloud.sh`, `docker/cloud-run/Dockerfile`, `test/e2e-browser/playwright.cloud.config.ts`. - Test: manual validation with recorded evidence in `/reports/cloud-validation.md` -**Test cases:** +**Test cases:** *(cloud selection must be explicit — see the Shipped-deviation note; `FRESHELL_E2E_BACKEND=cloud` is used below so the commands behave identically regardless of the caller's env)* - `scripts/e2e-cloud.sh build` — image builds and pushes successfully -- `scripts/e2e-cloud.sh run --project=chromium test/e2e-browser/specs/auth.spec.ts --reporter=line` — 6 passed on Cloud Run -- `scripts/e2e-cloud.sh run --project=chromium --reporter=line` — full chromium suite passes with similar counts to local baseline -- `scripts/e2e-cloud.sh run --shards=2 --project=chromium --reporter=line` — sharded run completes, combined results cover all tests +- `FRESHELL_E2E_BACKEND=cloud scripts/e2e-cloud.sh run --project=chromium test/e2e-browser/specs/auth.spec.ts --reporter=line` — 6 passed on Cloud Run +- `FRESHELL_E2E_BACKEND=cloud scripts/e2e-cloud.sh run --project=chromium --reporter=line` — full chromium suite passes with similar counts to local baseline +- `FRESHELL_E2E_BACKEND=cloud scripts/e2e-cloud.sh run --shards=2 --project=chromium --reporter=line` — sharded run completes, combined results cover all tests - [ ] **Step 1: Build and push the image** Run: `scripts/e2e-cloud.sh build` -Expected: Docker image builds locally and pushes to `us-west1-docker.pkg.dev/misc-puttering-project/freshell-e2e/freshell-e2e:latest`. +Expected: Docker image builds locally and pushes BOTH refs: the commit-addressed `us-west1-docker.pkg.dev/misc-puttering-project/freshell-e2e/freshell-e2e:` tag (what `run` resolves; a dirty tree tags `-dirty` and always rebuilds) and the rolling `:latest` pointer. - [ ] **Step 2: Create the Cloud Run Job** -Run: `scripts/e2e-cloud.sh run --project=chromium test/e2e-browser/specs/auth.spec.ts --reporter=line` +Run: `FRESHELL_E2E_BACKEND=cloud scripts/e2e-cloud.sh run --project=chromium test/e2e-browser/specs/auth.spec.ts --reporter=line` Expected: Cloud Run Job is created (if first run) and executed. 6 auth tests pass. Exit code 0. - [ ] **Step 3: Run the full chromium suite** -Run: `scripts/e2e-cloud.sh run --project=chromium --reporter=line` +Run: `FRESHELL_E2E_BACKEND=cloud scripts/e2e-cloud.sh run --project=chromium --reporter=line` Expected: Full chromium suite runs. Pass/fail counts are consistent with local baseline (within retry variance). Exit code 0 or 1 (1 if pre-existing failures match local baseline). - [ ] **Step 4: Test sharding** -Run: `scripts/e2e-cloud.sh run --shards=2 --project=chromium test/e2e-browser/specs/auth.spec.ts --reporter=line` +Run: `FRESHELL_E2E_BACKEND=cloud scripts/e2e-cloud.sh run --shards=2 --project=chromium test/e2e-browser/specs/auth.spec.ts --reporter=line` Expected: Two Cloud Run tasks execute. Combined, all auth tests are covered. Both tasks exit 0. @@ -345,6 +366,6 @@ git commit -m "test: validate Cloud Run Jobs end-to-end with smoke and full suit ## Notes - The `playwright.config.ts` refactor to export `MATRIX_SPECS` and `RUST_ONLY_SPECS` (Task 2) is a minimal DRY improvement that does not change any behavior. The base config's `export default defineConfig(...)` remains unchanged. -- The `package.json` change (Task 3) repurposes `test:e2e` from local to cloud. The old behavior is preserved as `test:e2e:local`. This is the user's explicit request: "Make that the new default with a flag For any other options like running locally." +- The `package.json` change (Task 3) was PLANNED to repurpose `test:e2e` from local to cloud, per the user's explicit request: "Make that the new default with a flag For any other options like running locally." **As shipped, it did not:** `test:e2e` routes through `scripts/e2e-cloud.sh run`, which defaults unset `FRESHELL_E2E_BACKEND` to LOCAL — the old behavior is fully preserved by default and cloud is opt-in (`test:e2e:cloud`, `--cloud`, or the env var). See the Shipped-deviation note at the top of this plan. - Cloud Run Jobs have a maximum execution time of 24 hours and a maximum of 256 tasks. The default 1-shard config runs all tests in one task; `--shards=N` splits across N parallel tasks. - The Docker image includes the Rust server binary for `rust-chromium` project support. The image will be large (~2-3 GB) due to Playwright browsers + Node deps + Rust binary. diff --git a/docs/plans/df1-evidence/AUTO-01.md b/docs/plans/df1-evidence/AUTO-01.md new file mode 100644 index 000000000..7b38dfd62 --- /dev/null +++ b/docs/plans/df1-evidence/AUTO-01.md @@ -0,0 +1,153 @@ +# AUTO-01 evidence — `ui.layout.sync` authoritative + +**Item (verbatim):** "Make `ui.layout.sync` authoritative. Replace the OpenCode-only +shadow layout with the real connected UI layout shared by browser, REST, CLI, and MCP. +Reverse mutations are owned by `AUTO-02` through `AUTO-11`." + +**Plan:** `docs/plans/df1/AUTO-01.md` (parity anchors + load-bearing ledger, 10/10 +claims verified inline before execution; residual risks R1–R5 recorded there). +**Worker:** `df1-auto-01-layout-sync-auth`. Base: `origin/df1/integration` (3dbba43c2). + +## What landed (branch `df1/auto-01-layout-sync-auth`) + +1. **`crates/freshell-freshagent/src/layout_store.rs`** — Rust port of legacy + `server/agent-api/layout-store.ts`: whole-snapshot last-write-wins ingest + (`updateFromUi`), the six-key/empty/filtered snapshot shapes EXACTLY (incl. + `timestamp`-when-fed), `listTabs`/`listPanes` (legacy tab resolution + `?tabId || activeTabId || tabs[0]`, tree-order leaves, title fallback), + derived pane-title seeding (`derivePaneTitle` port), `has_tab` (id OR title), + `getPaneSnapshot`/`resolvePaneToTerminal`/`findPaneByTerminalId`/ + `findSplitForPane`/`getSplitSizes`, and the mutation ops the existing Rust + routes need (`createTab`/`splitPane`/`closePane` incl. the legacy + buildGridLayout rebuild/last-pane guard, `selectTab`/`selectPane`, + `renameTab`/`renamePane` cascades, `closeTab`, `swapPane` incl. title travel, + `resizePane`, `attachPaneContent`). Plus an exact port of + `shared/fresh-agent.ts`'s `migrateLegacyFreshAgentContent/Node` + normalization (canonical-claude-id check hand-rolled; hand-verified table). +2. **WS ingestion**: `ClientMessage::UiLayoutSync` arm in + `crates/freshell-ws/src/terminal.rs` (`handle_client_text`) feeding the store + (never replies, per legacy). `FreshAgentState.layout_store` + + `FreshOpencodeState::fresh_agent()` accessors — zero new crate edges + (`freshell-ws` already depends on `freshell-freshagent`; `main.rs` already + shares ONE instance between WS and REST). +3. **Reads re-pointed at the store**: `GET /api/layout/snapshot` (real tree; + the fabricated `{type:'unknown',paneIds}` marker deleted), `GET /api/tabs` + (ordered, real `activeTabId`, legacy-exact `{id,title,activePaneId}` rows), + `GET /api/panes` (legacy tab resolution + tree order + seeded titles; + additive `tabId` KEPT — one recorded deviation), `GET /api/tabs/has` + (title arm restored, legacy-exact). The shadow `tabs`/`TabRecord` map was + REMOVED entirely (its last uses were the pre-AUTO-01 read surface). +4. **Write-through at existing mutation routes** (route contracts unchanged — + broadcasts, statuses, rollback all AUTO-02…11-owned): `POST /api/tabs` + (fresh-agent + terminal + browser/editor; written AFTER spawn success — no + rollback window, unlike legacy's create-first + catch-closeTab, recorded), + `split_pane` (legacy two-step: placeholder then `attachPaneContent`), + `close_pane`/`select_pane`/`select_tab`/`rename_tab`/`delete_tab` (resolve or + mutate through the store), `swap_pane`/`navigate_pane`/`respawn_pane` + (write-through alongside the kept dispatch shadow maps + `terminal_panes`/`content_panes`/`pane_tabs` — those REMAIN the + send/capture/wait-for resolution source until AUTO-09, see R4), + `retire_restore_key_content` closes the store tab. +5. **Honest deferral text updates** (state now exists; route contracts still + owned by AUTO-03/06): `tabs_next/prev`, `resize_pane`, `rename_pane`, + `attach_pane` — all still 400/200-with-deviation, text states the truth. + +## Deferred scope (recorded) + +- R2: per-connection `sidebarOpenSessionKeys` rebuild — legacy stores it but NO + production code reads it (only `test/server/ws-*` tests do). Not ported; + flagged for AUTO-14-adjacent follow-up if ever needed. +- R4: send-keys/capture/wait-for still resolve via the pre-existing shadow maps + (a mirror-only UI pane is not yet drivable); AUTO-09 owns that re-point. +- R3: malformed layouts — legacy zod rejects the whole frame; Rust + accept-and-strip stores with total normalization. Bounded divergence, documented. + +## Tests (TDD evidence) + +- `layout_store_tests.rs`: 41 unit tests (snapshot shapes, last-write-wins, + detached clones, migration table incl. nested splits/alias/sessionRef rules, + deriveTitle table, mutation ops incl. grid rebuild + title travel). + RED observed (todo!/missing-API compile-fail + assertion failures) → GREEN. +- `crates/freshell-ws/tests/ui_layout_sync.rs`: 4 integration tests — real WS + frame → store state (normalization + titles + source conn), last-write-wins + across two connections with source tracking, never-replies, and a + same-process WS→REST end-to-end (real axum REST server on the shared state; + `/api/layout/snapshot` + `/api/panes` + `/api/tabs` read back the WS-fed + layout). RED (2 ingest assertions failed pre-arm) → GREEN. +- Route tests in `pane_ops`/`terminal_tabs`: mirror-fed exact-tree snapshot, + title-based `tabs/has`, legacy pane-list resolution/order, mirror-only pane + close incl. last-pane guard, REST-create ordering/active-tab assertions — + plus the two pre-AUTO-01 reduced-fidelity tests rewritten to the + authoritative expectations (`unknown`-marker test → real split tree; + `/api/tabs` rows legacy-exact). +- Full suites: `freshell-freshagent --lib` 400/400; `freshell-protocol` green; + `freshell-ws` full (`--no-fail-fast`, `/tmp/auto01-ws-suite.log`) EXIT=0, + 45 binaries OK (one load-flake `auto_resume_e2e` timeout under swarm load, + re-run green in isolation — unrelated to this change; discipline per + df1 README B002). + +## Playwright probe (`layout-sync-authoritative.spec.ts`, MATRIX-registered) + +Two tests, both legs: (1) visible-UI-only create/rename/reorder/select/split/ +resize/close, then `/api/layout/snapshot` must equal the client's real layout +(tabs order/IDs, exact trees incl. dragged ratios, titles, active tab/pane); +(2) raw legacy `agent-chat` sync frame → normalized server-side snapshot + +pane rows (legacy = true parity control). + +Per-leg outcomes (probe rule: run once per relevant leg; classified): + +- `legacy-chromium`: **GREEN**. + - v1/v2 (2-worker): spec-side defects found by the probe and fixed — + (a) `.xterm` locator matched HIDDEN terminals of the inactive tab + (fixed with `:visible`); (b) an over-pinned resize assertion + (`sizes[0] != 50`) — a 50px drag leaves the divider at 50/50 on this + surface (same as pane-system.spec.ts's own drag test, which never + asserts sizes); the resize reflection stays covered by the exact + client↔REST equality poll. + - v3 (test 2 solo under `-g`, debugging a 2-worker timeout): PASS (40s). + The v1/v2 test-2 timeout was load/parallelism, not a defect. + - v4 (both tests, 2 workers, final spec): **PASS 2/2 (33.7s)**. +- `rust-chromium`: **GREEN**. First attempt 2/2 (1.6m incl. release rebuild) + at `4d7dac7c5`; **re-probed at the final review-complete code (2/2, 51.1s, + `/tmp/auto01-pw-rust-final.log`)** after the review-round Rust changes, so + the probe covers the final state. +- Probe logs: `/tmp/auto01-pw-legacy4.log`, `/tmp/auto01-pw-rust.log`, + `/tmp/auto01-pw-rust-final.log` (ephemeral; the MATRIX registration makes + both legs re-runnable any time). + +## Review loop (fresheyes independent reviews, GPT; Task tool unavailable in +this dispatch environment — the authorized fallback) + +- **Round 1** (pid 3154216, verdict FAILED → fixed): (major) vacuous + never-replies test → now reads the very next raw frame and asserts it IS the + pong; (major) evidence lacked per-leg probe classification → the section + above; (minor) `list_tabs` `""`-title fallback parity + regression test. +- **Round 2** (pid 3886807, verdict FAILED → fixed): (major) `split_pane` + trusted stale `pane_tabs` and spawned PTYs before authoritative rejection — + reordered to legacy's store-first shape (`router.ts:1305-1315`), with + stale-shadow + mirror-only regression tests; `respawn_pane`/`navigate_pane` + got the same store-first resolution. +- **Round 3** (pid 1364265, verdict FAILED → fixed): (major) `swap_pane` still + gated on the shadow map — rewritten store-first with legacy's + 200-`{message:'panes not found'}` decline shape (no 404 exists on the + legacy swap route); stale-shadow/mirror-only regression tests; (minor) + browser title hostname now lowercases (`new URL` canonicalization). +- **Round 4** (pid 3281298, verdict FAILED → fixed): (major) the store-first + swap left the fresh-agent session binding (`panes` map) behind — bindings + now follow swapped content (legacy's content-driven fresh-agent send + resolution parity), with a fresh-agent↔browser swap regression test; + (minor, recorded, not fixed) `url_hostname` is deliberately a cheap + extractor, not full WHATWG URL parity (IDNA/punycode, invalid-port rejects). +- **Round 5** (pid 679777): **PASSED** — "Only a minor edge-case issue + remains... stop iterating because only minor/nit issues remain." The nit is + the recorded `url_hostname` approximation (IDNA/punycode + invalid-port + corners vs `new URL`). + +## Verifier-facing GREEN commands (at final SHA) + +- `cargo test -p freshell-freshagent --lib layout_store` +- `cargo test -p freshell-freshagent --lib` +- `cargo test -p freshell-ws --test ui_layout_sync` +- `cargo test -p freshell-protocol` +- `cargo clippy -p freshell-freshagent -p freshell-ws --all-targets -- -D warnings` +- `npx playwright test --project=legacy-chromium --project=rust-chromium layout-sync-authoritative` (from `test/e2e-browser/`, after `npm run build:client build:server` + a release binary) diff --git a/docs/plans/df1-evidence/B005-HANDOFF.md b/docs/plans/df1-evidence/B005-HANDOFF.md new file mode 100644 index 000000000..21e1ad0ba --- /dev/null +++ b/docs/plans/df1-evidence/B005-HANDOFF.md @@ -0,0 +1,35 @@ +# B005 WRAP batch — handoff to final gate + +Gatekeeper: `df1-b005` · All four WRAP items merged into `df1/integration` in `.worktrees/df1-gate`, in dispatch order. Per-item wrap evidence (commands + verbatim greens + rebase checks): `B005-WRAP.md`. States: all four set to `merged-unverified-e2e`. Nothing pushed, no PRs opened. + +Integration lineage (tail): …`36b7e09b4` → merge JAN-88 `41aae0e9e` → ev `aab9065ed` → merge RESTORE-01 `d375ae565` → ev `8a14230ed` → merge SESSION-13 `b990df909` → ev → merge CFG-01 `cd375b5f6` → ev `7f91f359f` → this handoff. + +## Per item + +### JAN-88 — merge `41aae0e9e`, pre-merge rebased head `7147286fd5aaa89133c564c6a1645ea9ea655bce` +- Verification: a11y-gate:deny exit 0 (matches baseline); PW `harness-06-misc-fixtures.spec.ts --project=chromium` 10 passed; `test:e2e:helpers` 19 files / 256 tests green (one teardown-noise unhandled error on run 1, allowed flake-rerun clean); typecheck exit 0. +- Rebase: clean onto `36b7e09b4`; range-diff patch-identical; zero conflict resolutions. +- Injected-review findings noticed: none (evidence carries no review-loop section; spec-only change). +- Residuals handed off: none. + +### RESTORE-01 — merge `d375ae565`, pre-merge rebased head `5b8b563d53ada0d960aab125a0360cc893e6dc28` +- Verification: PW rust-chromium `recover-my-panes-rust.spec.ts` 3 passed (offer accept/decline/D7 pin the panel itself); `test:e2e:helpers` 20 files / 269 tests green (incl. 13 recovery-offer units); restore01 scoped tsc gate: zero item-attributable errors (only its two documented base TS2459s + pre-existing scoped-config dependency noise); typecheck exit 0. +- Rebase: clean onto `aab9065ed`; 5/5 commits range-diff patch-identical. +- Injected-review findings noticed: two self-found defects fixed pre-verification (project-colors tripled duplicate import; invalid `test.use` on raw `@playwright/test` import in sidebar-registry). Outstanding: none. +- Residuals handed off: (1) **multi-client rust divergence** — reconnect attach multiplicity 3 vs bound ≤2, probe-proofed watcher-independent, deterministic, F1-unmasked; candidate owner: reconcile lane (KNOWN; not failed for). (2) sidebar-registry-sync-rust case-c red at base (REST codex tab-create non-OK; rust-only spec; serial b/a/d blocked behind it). (3) One-off flakes observed, unattributed: editor-pane :68 loading-shell transient, contract-wall argv-log poll timeout. (4) Item worktree retains the verifier's uncommitted `gate01-baseline.json` collate bookkeeping (run-record appends only, zero verdict flips — inspected); left as found, deliberately unmerged. + +### SESSION-13 — merge `b990df909`, pre-merge rebased head `57bbd0db805c2e1379f2fa210069f0f791e6ec63` +- Verification: release binary rebuilt at rebased head (cargo lease); `cargo test -p freshell-server settings` 73 passed / 0 failed; vitest `settings-api.test.ts` 16 passed; PW rust-chromium `session-13-first-chat-exclusions.spec.ts` 1 passed on the freshly built slot binary; typecheck exit 0. TMPDIR redirected to `$HOME/.freshell/df1/tmp/s13-b005` (no git ancestor) to dodge the `/tmp/.git` poison. +- Rebase: clean onto `8a14230ed` despite the base delta touching the same `settings_store.rs` (disjoint regions: project-color rollback vs sidebar PATCH write path); 3/3 commits range-diff patch-identical. +- Injected-review findings noticed: structured fresh-eyes self-review recorded, findings none. +- Residuals handed off: (1) **stray empty `/tmp/.git`** on this host makes any default-TMPDIR full-bin run fail `repo_icon_git::tests::no_git_falls_back_to_start` — attribution trap for other workers; remove the dir or override TMPDIR. (2) HARNESS-02 follow-up: `RustServer.restart()` re-runs `setupHome` (can clobber PATCHed config.json on persistence legs; this item's spec guards around it). (3) Pre-existing load flake `network::tests::concurrent_configure_and_disable…` (same signature as CFG-01's NET-FLAKY-01). + +### CFG-01 — merge `cd375b5f6`, pre-merge rebased head `90421798552b092ca792fbde3325a3f07470a63d` +- Verification: binary rebuilt at rebased head; `settings_store::` scoped suite — first run 70 passed / **1 failed** (identity not captured), rerun + 12 further consecutive runs all green (13 green / 1 one-off; matches NET-FLAKY-01 one-off-under-load class, not deterministic); full bin suite 654 passed / 0 failed / 1 ignored; net09 1 passed; `cargo fmt --check` + `clippy -D warnings` exit 0; cfg01 scoped tsc gate zero item-attributable errors (7 pre-existing dependency-noise lines only; RESTORE-01's fixtures.ts tuple fix visibly landed); PW rust-chromium `cfg01-lossless-writes` 2 passed; typecheck exit 0. +- Rebase: clean onto `87eeeebf6` (post-SESSION-13 `settings_store.rs` test-module additions disjoint); 9/9 commits range-diff patch-identical. +- Injected-review findings noticed: review subagent's two actionable findings, both fixed in-branch — [P2] port-steal deflake (f3wp retry + identity check), [P3] failure-path cleanup sweep. Non-actionable residuals it recorded are documented non-goals. +- Residuals handed off: (1) **NET-FLAKY-01** filed in the queue (`network::tests::concurrent_configure_and_disable_serialize_to_a_consistent_end_state`, pre-existing). (2) The one-off `settings_store::` flake observed during this gate's own run — same class; watch for recurrence in the final gate. (3) CFG-02 (cross-process settings residual), CFG-11 (crash-mid-write atomicity), SESSION-03/TERM-* cascade parity remain if-scheduled follow-ups. + +## Housekeeping +- Leases: every `pw`/`cargo` lease acquired as `df1-b005` was released after its block (final occupancy check shows only the df1-gate-final agent's holders). Verifier slots (`df1-verify-j88/r01/s13/c01`) held no leases at wrap end; ran `acquire.sh agent-kill …` per dispatch for all four (no-op → help) plus a semantic `release agent` for each. +- TMPDIR scratch dirs left in `$HOME/.freshell/df1/tmp/{s13,c01}-b005` (harmless; outside any repo). diff --git a/docs/plans/df1-evidence/B005-WRAP.md b/docs/plans/df1-evidence/B005-WRAP.md new file mode 100644 index 000000000..96efc1d2a --- /dev/null +++ b/docs/plans/df1-evidence/B005-WRAP.md @@ -0,0 +1,70 @@ +# B005 WRAP batch — gate evidence + +Gatekeeper: `df1-b005` · Integration branch: `df1/integration` (gate worktree `.worktrees/df1-gate`) · Tip at batch start: `36b7e09b4` + +Items merged in order: JAN-88, RESTORE-01, SESSION-13, CFG-01. Each entry: freshness re-audit, rebase content check, verbatim green verification summaries, merge sha. Freshness note: the naive `git diff --stat 4c2297667..` includes the whole moved-integration delta (branches are based on `5521f3aba`+); the scope check is therefore done against each branch's merge-base with the integration tip (equals its declared base), plus `git range-diff` for rebase content preservation. + +--- + +## JAN-88 — fix 3 novel a11y-gate violations in harness-06-misc-fixtures spec + +- Branch: `df1/fix-h06-a11y` · attested head `ec53c451067f7361c90651f9eae27ecdbd190353` +- Freshness: `git rev-parse df1/fix-h06-a11y` = attested sha ✓. Merge-base with integration = declared base `5521f3aba` ✓. Own commits: exactly one (`df1(JAN-88): fix 3 novel a11y-gate violations…`) touching only `docs/plans/df1-evidence/JAN-88.md` + `test/e2e-browser/specs/harness-06-misc-fixtures.spec.ts` — in declared scope ✓. Spec test `target server: ws echo round-trips text+binary …` present (L125) with the `getByText` fixes at L135/141/148; remaining `ws-open`/`ws-message` matches are ledger event kinds, not CSS locators ✓. +- Rebase onto `36b7e09b4`: clean, no conflicts. New head `7147286fd5aaa89133c564c6a1645ea9ea655bce`. `git range-diff 5521f3aba..ec53c4510 36b7e09b4..7147286fd` → `1: ec53c4510 = 1: 7147286fd` (patch-identical); attested→new-head file list identical to the base-delta (`5521f3aba..36b7e09b4`) file list — zero conflict resolutions ✓. +- Verification (all in item worktree, `nice -n 19`; pw legs under `pw` lease `df1-b005`, released after): + - `npm run test:e2e:a11y-gate:deny` → **exit 0**: `deny: scan matches baseline — no novel violations, no stale entries.` + - `npx playwright test --config test/e2e-browser/playwright.config.ts harness-06-misc-fixtures.spec.ts --project=chromium` → **`10 passed (1.3m)`, exit 0** + - `npm run test:e2e:helpers` → run 1 exit 1 with all tests green (`Test Files 19 passed (19)`, `Tests 256 passed (256)`) plus one vitest teardown-phase unhandled error; allowed flake-rerun: run 2 **exit 0, no error lines** (19 files / 256 tests). + - `npm run typecheck` → **exit 0** +- **Merge: `41aae0e9e`** `df1(B005): JAN-88 fix 3 novel a11y-gate violations in harness-06-misc-fixtures spec` (merge via ort, spec+evidence only — 2 files, +68/−3) + +--- + +## RESTORE-01 — recover-my-panes offer inert for e2e harness (auto-decline watcher) + +- Branch: `df1/restore-01-panel-inert` · attested head `1e4f3162aa883a72f9c40242bfa6f517c3dd6ba4` +- Freshness: rev-parse = attested ✓. Merge-base with integration = declared base `5521f3aba` ✓. 5 own commits, diff confined to `docs/plans` + `test/e2e-browser` (zero product code) ✓. Branch tests remain: 13 `it(...)` in `test/e2e-browser/helpers/recovery-offer.test.ts`, `recover-my-panes-rust.spec.ts` scenarios 1–3, `tsconfig.restore01-check.json` ✓. Worktree carried uncommitted `gate01-baseline.json` collate bookkeeping (run-record appends matching the verifier's re-runs incl. the known 5p/1f multi-client divergence; head/sha stamp to attested sha; zero verdict/attribution flips — verified by diff inspection) — benign tool output, left uncommitted; stashed aside for the rebase and restored after. +- Rebase onto `aab9065ed`: clean, no conflicts. New head `5b8b563d53ada0d960aab125a0360cc893e6dc28`. `git range-diff 5521f3aba..1e4f3162 aab9065ed..5b8b563d5` → all 5 commits patch-identical (`=`); attested→new-head file list identical to base-delta file list ✓. +- Verification (item worktree, `nice -n 19`; pw legs under `pw` lease `df1-b005`, released after): + - `FRESHELL_E2E_RUST_SERVER_BIN=$PWD/../../target/release/freshell-server npx playwright test --config playwright.config.ts --project=rust-chromium recover-my-panes-rust.spec.ts` (from `test/e2e-browser`) → **`3 passed (2.1m)`, exit 0** — offer accept/decline/D7 scenarios pin the panel itself. + - `npm run test:e2e:helpers` → **`Test Files 20 passed (20)`, `Tests 269 passed (269)`, exit 0** (includes the 13 recovery-offer unit tests). + - `npx tsc -p test/e2e-browser/tsconfig.restore01-check.json` → exit 2; per the config's own contract ("zero error lines attributable to the files RESTORE-01 created or edited"), ALL errors are the declared base-reproducible noise: 2× TS2459 `TestServerInfo` in the two rust-only specs (named in evidence) + TS2339/TS2304 in `src/lib/client-logger.ts`, `src/lib/perf-logger.ts`, `src/store/settingsSlice.ts` — pre-existing dependency files byte-identical to base (RESTORE-01 touches no `src/`). **Zero item-attributable errors.** + - `npm run typecheck` → **exit 0** +- KNOWN divergence noted (not failed for): rust reconnect attach multiplicity on `multi-client.spec.ts` "reconnecting second viewer…" (3 reconnect-shaped `terminal.attach` vs bound ≤2; probe-proofed watcher-independent per item evidence; candidate owner: reconcile lane). Also handed off: sidebar-registry-sync-rust case-c pre-existing red at base. +- **Merge: `d375ae565`** `df1(B005): RESTORE-01 recover-my-panes offer inert for e2e harness (auto-decline watcher)` (ort, 12 files, +1264/−268) + +--- + +## SESSION-13 — legacy-parity PATCH write path for sidebar first-chat exclusions + +- Branch: `df1/session-13-first-chat-exclusions` · attested head `0124d2dad332d475ff2a86d757a85f610620fff2` +- Freshness: rev-parse = attested ✓. Merge-base with integration = declared base `5521f3aba` ✓. 3 own commits; diff = `crates/freshell-server/src/{settings_store.rs, session_directory.rs}` + `docs/plans` + `playwright.config.ts` + the item spec + `sidebarSelectors.visibility.test.ts` — in declared scope ✓. Worktree clean at attested head ¶ Item tests remain (spec + unit selector test + 73 settings-scoped crate tests). +- Rebase onto `8a14230ed`: base delta also touched `settings_store.rs` (project_color rollback, 3 hunks) but disjoint from SESSION-13's PATCH-write-path hunks → **no conflicts**. New head `57bbd0db805c2e1379f2fa210069f0f791e6ec63`. `git range-diff 5521f3aba..0124d2dad 8a14230ed..57bbd0db8` → all 3 commits patch-identical (`=`); attested→new-head file list identical to base-delta list ✓. +- Verification (item worktree, `nice -n 19`; rust tree changed by rebase → release binary **rebuilt under cargo lease**; TMPDIR=`$HOME/.freshell/df1/tmp/s13-b005` — evidence-documented stray empty `/tmp/.git` poisons default-TMPDIR `repo_icon_git` test; confirmed clean TMPDIR has no git ancestor): + - `cargo build --release -p freshell-server` → exit 0 (1m22s) + - `cargo test -p freshell-server settings` (cargo lease) → **`73 passed; 0 failed`** (bin target; all other targets 0 matched-fail), exit 0 + - `npm run test:vitest -- run test/integration/server/settings-api.test.ts --config config/vitest/vitest.server.config.ts` → **`Test Files 1 passed (1)`, `Tests 16 passed (16)`, exit 0** + - pw lease → `FRESHELL_E2E_RUST_SERVER_BIN=$PWD/target/release/freshell-server npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium test/e2e-browser/specs/session-13-first-chat-exclusions.spec.ts` → **`1 passed (26.7s)`, exit 0** (harness logged slot binary sha256 4e38f60d…) + - `npm run typecheck` → exit 0 + - Leases `cargo` + `pw` acquired/released as `df1-b005`. +- **Merge: `b990df909`** `df1(B005): SESSION-13 legacy-parity PATCH write path for sidebar first-chat exclusions` (ort, 7 files, +1342/−2) + +--- + +## CFG-01 — lossless config.json writes (sentinel coverage + net09 + PW probe) + +- Branch: `df1/cfg-01-lossless-writes` · attested head `c2d909684894f312af601b591ae9ce5b7ba65e03` +- Freshness: rev-parse = attested ✓. Merge-base with integration = declared base `5521f3aba` ✓. 9 own commits; diff = `settings_store.rs` (test-module only per evidence), `tests/net09_config_preservation.rs`, `docs/plans`, `playwright.config.ts`, `cfg01-lossless-writes.spec.ts`, `tsconfig.cfg01-check.json` — in scope ✓, worktree clean at attested head. +- Rebase onto `87eeeebf6` (post-SESSION-13 tip): **no conflicts** (SESSION-13 and CFG-01's `settings_store.rs` test-module additions are disjoint regions). New head `90421798552b092ca792fbde3325a3f07470a63d`. `git range-diff 5521f3aba..c2d909684 87eeeebf6..904217985` → all 9 commits patch-identical (`=`); attested→new-head file list identical to base-delta list ✓. +- Verification (item worktree, `nice -n 19`; TMPDIR=`$HOME/.freshell/df1/tmp/c01-b005` because of the documented stray empty `/tmp/.git` poison; release binary rebuilt at rebased head for the PW leg; leases cargo→pw as `df1-b005`, both released): + - `cargo build --release -p freshell-server` → exit 0 (57s) + - `cargo test -p freshell-server --bin freshell-server settings_store::` → **first run: 70 passed / 1 failed (exit 101)** — one-off; failure identity not captured on the first run. Classification evidence: rerun green, then 12 further consecutive runs of the identical suite all green (**13 green / 1 one-off**), matching the campaign's documented one-off-under-load flake class (NET-FLAKY-01 pattern; run overlapped with heavy `nice`d builds). Not deterministic → proceeded per the flake-rerun rule. Final suite result: **`71 passed; 0 failed`**. + - `cargo test -p freshell-server --bin freshell-server` (full sentinel gate) → **`654 passed; 0 failed; 1 ignored`, exit 0** + - `cargo test -p freshell-server --test net09_config_preservation` → **`1 passed; 0 failed`** + - `cargo fmt --check` → exit 0 · `cargo clippy -p freshell-server --all-targets -- -D warnings` → exit 0 + - `npx tsc -p test/e2e-browser/tsconfig.cfg01-check.json` → exit 2; **zero errors in CFG-01 files** — all 7 lines are the pre-existing scoped-config dependency noise (`import.meta.env`/`__PERF_LOGGING__` in `src/lib/client-logger.ts`, `src/lib/perf-logger.ts`, `src/store/settingsSlice.ts`; files untouched by the item). Notably the old `helpers/fixtures.ts` tuple noise is GONE — RESTORE-01's fix landed upstream, confirming merge composition. + - `npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium cfg01-lossless-writes` (pw lease, `FRESHELL_E2E_RUST_SERVER_BIN` = freshly built binary) → **`2 passed (29.7s)`, exit 0** + - `npm run typecheck` → exit 0 +- **Merge: `cd375b5f6`** `df1(B005): CFG-01 lossless config.json writes — sentinel coverage + net09 + PW probe` (ort, 7 files, +1199/−4) + +--- diff --git a/docs/plans/df1-evidence/BROWSER-01.md b/docs/plans/df1-evidence/BROWSER-01.md new file mode 100644 index 000000000..b1d1ac6c3 --- /dev/null +++ b/docs/plans/df1-evidence/BROWSER-01.md @@ -0,0 +1,73 @@ +# BROWSER-01 — Complete same-origin HTTP reverse proxying — df1 evidence + +**Item (verbatim):** *Complete same-origin HTTP reverse proxying. Preserve method/path/query/body/useful headers/status/streaming while removing only iframe-blocking headers.* + +**Branch:** `df1/browser-01-same-origin-proxy` (base `origin/df1/integration` @ `3dbba43c2`) · **Date:** 2026-08-09 · **Playwright posture:** `deferred` (spec authored + registered in `MATRIX_SPECS`; probe-executable rule satisfied — ONE probe run per leg under the pw lease, per-leg outcomes classified below). + +**Parity source:** legacy `server/proxy-router.ts:79–129` (HTTP half of `/api/proxy`), with `express.json({limit:'1mb'})` + `httpAuthMiddleware` mounted before it (`server/index.ts:186,209–212,863`). + +## What was MISSING on base (gap audit vs legacy) + +The base already had `crates/freshell-server/src/proxy.rs` (oracle §3.18 port). Gap-hunting against the legacy contract found **four real parity breaks**, each RED-proven before fixing: + +| Gap | Base behavior | Legacy | Fix | +|---|---|---|---| +| G1 | `Path` catch-all percent-**decoded** the tail → `%2F`→`/`, upstream saw a mutated route | raw `req.url` forwarded (`proxy-router.ts:99`) | port/rest parsed off the RAW `uri.path()` | +| G2 | `HeaderMap::insert` collapsed duplicate headers both directions → 2nd `Set-Cookie` won, login flows break | Node header arrays forwarded verbatim | `append` in both copy loops | +| G3 | `Bytes` extraction fully buffered the request body AND hit axum's 2 MiB `DefaultBodyLimit` → 413 on large uploads | `req.pipe(proxyReq)` streams any size | `Body` extraction → `reqwest::Body::wrap_stream`, attach only when a body is declared, original `content-length` forwarded (probe L5 semantics) | +| G4 | upstream `content-length` stripped from responses | `writeHead` forwards it | removal set = 3 iframe-blockers + only truly hop-by-hop framing (`connection`/`transfer-encoding`/`keep-alive`) | + +Deliberate divergence (recorded, strictly stronger than legacy): Rust forwards raw JSON bodies byte-exact where legacy re-serialized compact JSON (and 413'd JSON >1MB inside `express.json`, pre-route). Any JSON parser sees an equivalent document; the wire-level unit tests pin the byte-exact behavior. + +## Load-bearing audit (all empirically verified — `proxy.rs::lb_probes`) + +L1 axum routes match percent-encoded paths AND `uri.path()` is raw/undecoded in the handler · L2 `HeaderMap::insert` replace-all vs `append` preserve-order · L3 `Bytes` 2 MiB cap (413) vs uncapped `Body` · L4 reqwest (default-features=false+stream+rustls) injects NO `accept-encoding` and does NO decompression (51-byte real gzip passthrough, byte-exact) · L5 `wrap_stream` + explicit `content-length` puts that exact length on the wire (no chunked) · L6 legacy `req.url` rawness (inspection, `proxy-router.ts:99`). Full ledger: `docs/plans/df1/BROWSER-01.md` §Load-bearing audit. + +## PROVEN (green, x2 consecutive) + +- **Unit/socket matrix (raw TCP both sides, zero framework normalization)** — `cargo test -p freshell-server proxy` → **24/24, two consecutive runs** (0.59s, 0.65s). Covers: every-method forwarding; 11-case byte-exact raw path+query matrix (encoded slash/space/percent/question-mark, UTF-8, plus/repeated/empty query keys, trailing slash, bare root); duplicate `set-cookie` + duplicate request headers preserved in wire order; 3 MiB upload with original `content-length` (no 413, no chunked re-frame); signal-gated incremental CHUNKED UPLOAD arrival (deadlock-guaranteed, no sleeps); statuses 201/302/404/418 verbatim with 302 `location` (redirects never followed); legacy-exact 400/401/502 JSON; cookie auth; useful-header passthrough with host rewrite + hop-by-hop drop; signal-gated response DRIP streaming (client sees chunk 1 while upstream holds chunk 2); HEAD without body-hang; byte-exact pretty-JSON/binary bodies; gzip passthrough with `content-encoding` intact; real-binary wiring (see next). +- **Black-box mounted-app proof** — `cargo test -p freshell-server --test browser01_proxy` → **1/1, two consecutive runs** (~0.25s each). Boots the REAL `freshell-server` binary (diag01 pattern), drives raw-socket GET (three-blocker strip, multi `set-cookie` + `content-length` survive the real rate-limit/charset layers) + POST (`/a%2Fb/c%20d?q=%2F&n=1+2` byte-exact) + 400/401/502 shapes. +- **Frozen-legacy control (scoped vitest)** — `npm run test:vitest -- run test/unit/server/proxy-router.test.ts` → **209/209 passed across 11 matched files** (legacy tree untouched, stays green). +- **Typecheck** — `npx tsc -p tsconfig.json --noEmit` ✓, `npx tsc -p tsconfig.server.json --noEmit` ✓, spec-attributed errors `npx tsc -p test/e2e-browser/tsconfig.browser01-check.json` → **0** · `cargo fmt --check` ✓ · `cargo clippy -p freshell-server --all-targets` **0 warnings**. + +## Playwright spec (authored; probe-run classification) + +`test/e2e-browser/specs/browser01-proxy.spec.ts`, registered in `MATRIX_SPECS` (one additive line — the ONLY shared-file edit). In-spec fixture serves an interactive page under `X-Frame-Options: DENY` + `CSP frame-ancestors 'none'` (+CSP-RO): renders in the Browser pane ONLY if exactly those headers are stripped. Through `frameLocator`: GET form → browser-visible `RAW:url=/query-submit?q=a%2Fb%2Bc+d`; POST form → browser-visible `POST-RECEIVED:message=hello+world&sigil=P%25ss%2F%26%3D%3F`; `fetch` echo → `"url":"/api/echo?x=%2F&plus=1+2"` + `sawCookie:true`; streaming route → `STREAM-FIRST;` visible then `STREAM-FIRST;STREAM-SECOND;`. Upstream capture assertions pin the exact raw inputs (incl. `freshell-auth` cookie on the iframe navigation). + +Per-leg probe outcomes (pw lease, one probe per leg + confirmation): +- **legacy-chromium:** PASS ×2 (1.1m cold incl. client build; 30.4s warm) — true parity control, identical assertions green on legacy. +- **rust-chromium:** run 1 FAIL — cold-start artifact: the release-profile `freshell-server` build (`ensureRustServerBuilt`) exceeded the per-test window on first touch (same documented cold-run pattern as SAFE-01's annotation); runs 2–3 PASS (35.4s, 29.2s) with the current-branch binary. Classified environmental, not a spec/product failure. + +## Review loop (2 rounds; Task-tool subagent unavailable in this environment → recorded structured fresh-eyes self-review fallback per dispatch) + +- **Round 1 (product diff):** one finding — `[P3, pre-existing]` reqwest's builder defaults to system-proxy autodetection (`HTTP_PROXY`/`HTTPS_PROXY`), which could shunt the always-loopback upstream through an env proxy; legacy Node's `http.request` never consults env proxies. **Fixed:** `.no_proxy()` on the client builder (+comment), commit `review round 1`. No P0/P1/P2 findings in the G1–G4 diff. +- **Round 2 (test harness + spec):** one finding — the black-box test could orphan the spawned `freshell-server` on a panicking assert (std `Child` doesn't kill on drop). **Fixed:** kill-on-drop `ChildGuard`, commit `review round 2`. Spec + MATRIX registration + tsconfig gate reviewed clean. +- Loop closed: no remaining serious findings. + +**Final verification at head `32301f6de`:** cargo proxy 24/24 · black-box 1/1 (×2 consecutive incl. earlier runs) · clippy 0 warnings · `cargo fmt --check` clean · spec-attributed tsc errors 0 · control vitest 209/209 · PW legacy PASS (30.3s) · PW rust PASS (55.4s, warm binary). + +## What this item does NOT cover (owned by siblings) + +- `BROWSER-02` WS-upgrade proxying (legacy `attachProxyUpgradeHandler`, `proxy-router.ts:144–219`) — separate item, untouched. +- `BROWSER-03/04` `/api/proxy/forward` TCP port-forward + destination/requester restrictions — `proxy.rs`'s module doc already scopes them out; unchanged. +- `BROWSER-05` failure/retry UI + screenshot determinism — client-side; untouched. + +## Files + +- `crates/freshell-server/src/proxy.rs` — the four fixes (~25 LOC of product change) + `wire_support` raw-socket fixtures + `lb_probes` + `socket_contract*` (24 tests). +- `crates/freshell-server/tests/browser01_proxy.rs` — black-box mounted-app proof (new). +- `test/e2e-browser/specs/browser01-proxy.spec.ts` + `tsconfig.browser01-check.json` (new); `playwright.config.ts` (one additive MATRIX_SPECS line). +- `docs/plans/df1/BROWSER-01.md` — plan + load-bearing ledger (new). + +## GREEN COMMANDS (verifier: re-run at head SHA) + +```bash +nice -n 19 cargo test -p freshell-server proxy +nice -n 19 cargo test -p freshell-server --test browser01_proxy +nice -n 19 cargo clippy -p freshell-server --all-targets +nice -n 19 cargo fmt -p freshell-server -- --check +npx tsc -p test/e2e-browser/tsconfig.browser01-check.json # require: 0 lines attributed to specs/browser01-proxy.spec.ts +npm run test:vitest -- run test/unit/server/proxy-router.test.ts +nice -n 19 npx playwright test --config test/e2e-browser/playwright.config.ts specs/browser01-proxy.spec.ts --project=legacy-chromium --workers=1 +nice -n 19 npx playwright test --config test/e2e-browser/playwright.config.ts specs/browser01-proxy.spec.ts --project=rust-chromium --workers=1 +``` diff --git a/docs/plans/df1-evidence/CFG-01.md b/docs/plans/df1-evidence/CFG-01.md new file mode 100644 index 000000000..89e1ce4c4 --- /dev/null +++ b/docs/plans/df1-evidence/CFG-01.md @@ -0,0 +1,185 @@ +# CFG-01 evidence — lossless `config.json` writes + +**Item:** Make every `config.json` write lossless (preserve `sessionOverrides`, +`terminalOverrides`, `projectColors`, `recentDirectories`, `completedMigrations`, +`legacyLocalSettingsSeed`, Codex secrets, unknown future keys — on every writer). +**Branch:** `df1/cfg-01-lossless-writes` (base `origin/df1/integration` @ `5521f3aba`). +**Plan:** `docs/plans/df1/CFG-01.md` (writer inventory, gap matrix G1–G5, non-goals). + +## State on base (verified, not assumed) + +The lossless writer itself landed in Batch A (`6e3af242`, already an ancestor of +`origin/df1/integration`): `SettingsStore::persist()` reads the on-disk document and +overlays only owned keys, copy-forwarding everything else; Batch B added the +adopt-from-disk + dirty-key overlay for `sessionOverrides`/`terminalOverrides`/ +`projectColors` and the advisory `ConfigLock`; CFG-03 added the atomic backup refresh; +CFG-04 added `legacyLocalSettingsSeed` ownership. **Every** production `config.json` +write funnels through `persist()` (exhaustive grep+read sweep: settings PATCH, +terminal override PATCH/DELETE, session override PATCH, project-color PUT, network +mutations via `settings.patch`, boot normalization persist; `instance_id.rs` and +`session_metadata.rs` write separate files; only test code writes `config.json` +directly). Legacy parity source read: `server/config-store.ts` `saveInternal` / +`{...existing, ...updates}` (:344-362 etc.). Legacy's OWN normalization rebuild drops +unknown keys *inside* `settings` and sibling secrets — Rust is a deliberate superset; +both divergences are documented in the plan as non-goals (frozen parity). + +## What this item added + +### Crate coverage (TDD regression-pinning — RED protocol below) + +1. **G1 — shared sentinel breadth** (`settings_store.rs` `lossless_fixture_text()` + + `assert_unmanaged_document_state_preserved()`): added `legacyLocalSettingsSeed` + (CFG-04 ownable key, canonical order per `seeded_boot_is_byte_stable_on_second_boot`) + and a **sibling** secret `serverSecrets.futureSiblingSecret`. One change strengthens + all three existing writer legs (settings patch, terminal-override patch, + session-override patch). +2. **G2 —** `project_color_write_preserves_unmanaged_top_level_document_state`: the + project-color writer (`PUT /api/project-colors` → `set_project_color`) against the + full sentinel fixture (the existing project-color family used reduced fixtures). +3. **G3 —** `boot_provider_seed_persist_preserves_unmanaged_top_level_document_state` + and `boot_seed_strip_persist_preserves_unmanaged_top_level_document_state`: the two + boot-time normalization persist triggers (`knownProviders` seed; stray-local-key + strip) against the full sentinel fixture incl. pre-existing override/color entries. +4. **G4 —** `tests/net09_config_preservation.rs`: added the `legacyLocalSettingsSeed` + sentinel to the spawned-real-binary network-writer byte-preservation + restart leg. + +### PW-RUST spec (the reconciliation-named missing piece) + +5. **G5 —** `test/e2e-browser/specs/cfg01-lossless-writes.spec.ts`, registered + rust-only (`RUST_ONLY_SPECS` + rust-chromium `testMatch`; not `MATRIX_SPECS` — + legacy cannot be a parity control for guarantees it never provided, see the spec's + doc comment). Two tests: + - *every REST writer preserves all sentinels; restart writes nothing* — fresh boot + lands the first write; sentinel block injected (incl. sibling secret, keeping the + server's own minted codex secret); **restart leg** asserts a fully-normalized + config boots to a semantic no-op (zero diff paths); then six writer actions + (settings save, terminal rename, terminal delete, session mutation, project + color, network configure) each followed by a structural deep-compare of + `config.json` allowing ONLY that writer's intended paths (diff paths are key + arrays — session keys contain `:`, project paths contain `/`) plus per-key + sentinel deep-equality; final cumulative diff ⊆ union of intended paths. + - *boot writers preserve all sentinels* — `knownProviders` removed AND stray + browser-local keys injected into `settings` (both boot triggers fire on one + boot); diff ⊆ `{settings.codingCli.knownProviders, settings.theme, + settings.uiScale, legacyLocalSettingsSeed}`; seed content asserted. + - Provider discovery is pinned EMPTY (`FRESHELL_EXTENSIONS_DIR` + neutral cwd) so + `knownProviders: []` boot behavior is deterministic. + - Named writers that do not exist in Rust (recent-directory MRU — CFG-09 open; + title migrations) are covered as preservation sentinels, documented in the spec. + +### Typecheck gate + +`test/e2e-browser/tsconfig.cfg01-check.json` (house per-item convention, mirrors +TERM-04/HARNESS-05): `npx tsc -p test/e2e-browser/tsconfig.cfg01-check.json` → +**zero errors attributed to the spec** (13 output lines, all the pre-existing +`helpers/fixtures.ts` worker-scope tuple error that reproduces identically on base). + +## RED/GREEN proofs + +### Crate + +- **GREEN (real code):** `cargo test -p freshell-server --bin freshell-server + settings_store::` → 60 passed, 0 failed (includes the 3 extended + 3 new lossless + tests). `cargo test -p freshell-server --test net09_config_preservation` → 1 passed. +- **RED (hand-spliced regression, never committed):** `persist()` body replaced with + the pre-`6e3af242` fixed-key-set rebuild (`git show 6e3af242^:...`; drops + `completedMigrations`/`legacyLocalSettingsSeed`/sibling secret/`zzFutureKey`, + empties `recentDirectories`). Result: ALL SIX lossless tests FAILED + (`settings_patch_…`, `terminal_override_patch_…`, `session_override_patch_…`, + `project_color_write_…`, `boot_provider_seed_persist_…`, + `boot_seed_strip_persist_…`); control `agent_chat_key_rejected` still passed + (splice is surgical). Restored via `git reset --hard HEAD` (green checkpoint + commit) → 60/60 GREEN again. +- **net09 rationale:** the seed key is written from memory by `persist()`; the + restart leg proves a stored canonical seed needs no normalization write (else the + byte-hash compare would catch it). + +### PW probe (deferred-with-probe posture: authored on the item branch, probed here) + +`npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium cfg01-lossless-writes` (each run rebuilds client+server via global setup; the spec itself builds/uses `target/release/freshell-server` at the branch HEAD): + +- Run 1 (authored spec): **1 failed / 1 not-run** — deterministic SPEC-logic bug, found by the + probe exactly as intended: the per-action sentinel check ran bit-for-bit on the + writer-under-test's own managed key (`terminalOverrides` during the rename leg). +- Run 2 (containment semantics added): **1 failed / 1 not-run** — second deterministic + spec-logic bug of the same class, one leg later: the delete leg legitimately mutates the + rename leg's entry (`+deleted:true`); containment needed a mutation-target exception with + field-level survival. +- Run 3: **2 passed (22.5s)** — all legs green: restart zero-diff, settings save, terminal + rename, terminal delete, session mutation, project color, network configure, cumulative + deep-compare, both boot-writer triggers. +- Run 4 (confirmation, final spec SHA `1ce0e7528`): **2 passed (47.7s)** — two consecutive + greens; earlier failures were deterministic assertion-semantics fixes, not flakes. +- Post-review-fix runs at the FINAL code SHA `b5d40236e` (after the P2/P3 spawn-hardness + fixes): **2 passed (39.3s)**, then two fresh runs at that SHA **2 passed (1.0m)** and + **2 passed (52.4s)**. Three consecutive greens at final SHA. + +## Final verification gate (verbatim, at `b5d40236e`) + +- `cargo test -p freshell-server --bin freshell-server settings_store::` → **60 passed, 0 failed** +- `cargo test -p freshell-server --test net09_config_preservation` → **1 passed, 0 failed** +- `cargo test -p freshell-server --bin freshell-server` (full scoped gate, x2: first run hit the + pre-existing `NET-FLAKY-01` flake [see below]; retry → **641 passed, 0 failed**) +- `cargo fmt --check` → clean +- `cargo clippy -p freshell-server --all-targets -- -D warnings` → clean +- `npx tsc -p test/e2e-browser/tsconfig.cfg01-check.json` → zero errors attributed to the spec +- `npx eslint test/e2e-browser/specs/cfg01-lossless-writes.spec.ts test/e2e-browser/playwright.config.ts` → 0 errors (files outside eslint's configured scope, as with sibling e2e specs) +- `npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium cfg01-lossless-writes` → **2 passed x3 consecutive** + +No product bug found by the probe (the writer was already lossless on base); the probe's +value-add was hardening the spec's own assertion semantics into non-vacuous form +(writer-targeted keys use allowed-path diff restriction + entry containment, everything else +bit-for-bit). + +### Review loop + +No `Task` tool exists in this harness; the freshell-pane agent attempt (`new-tab +agent=opencode`) timed out server-side with no tab created. **Fallback used:** fresh review +subagent via `opencode run` CLI (read-only review-agent rules, defect-first), cwd = the +worktree. The subagent verified the full claim chain itself (fixture canonical-key-order vs +`legacy_local_seed.rs`, store.callers, all five Playwright project registrations incl. the +gate01 config's shared `RUST_ONLY_SPECS` import, route semantics for every leg) and returned +**two actionable findings, both fixed in this branch**: + +1. **[P2] port-steal deflake missing** in the spec's hand-rolled spawn (bare `findFreePort` + TOCTOU + token-blind `/api/health` poll). Fixed by mirroring `RustServer.start`'s kata-f3wp + remedy exactly: 3-attempt boot loop, fresh port per attempt, token-gated + `/api/server-info` identity check with a 2s `AbortSignal` timeout, retry only on + bind-race-shaped failures, kill-child-only between attempts. +2. **[P3] no failure-path cleanup** — a mid-test assertion failure orphaned a listening + server. Fixed: `liveChildren` tracking, test-scope `afterEach` SIGKILL sweep, and + `stopProcessGracefully`/`killChildNow` untrack on success paths. + +Non-actionable residual risks it recorded (all documented non-goals): provider-migration PW +leg covers seed-when-missing only (append branch has crate coverage); Batch-B cross-process +settings residuals (CFG-02's queue); cascade parity out of scope (SESSION-03/TERM-*); seed +key-order-canonicality assumption (fixtures match the extractor's assignment order — a future +seed field needs the same care). + +Its overall assessment: *"The tests substantively prove what they claim — this is not vacuous +coverage."* + +## Foreign flake encountered (NOT this item, classified + filed) + +`cargo test -p freshell-server --bin freshell-server` (full scoped gate, 640 tests): one failure +— `network::tests::concurrent_configure_and_disable_serialize_to_a_consistent_end_state` +(`network.rs:2917`, "persisted host desynced from live bind (A-08)"). Classification: +**pre-existing flake, unrelated to CFG-01** — (1) this item's diff contains ZERO production-code +changes (all crate edits are inside `#[cfg(test)]`; `git diff 5521f3aba..HEAD -- +crates/freshell-server/src` outside `settings_store.rs`'s test module is empty); (2) the test +flakes when run SOLO at this HEAD (1-in-5 repro), where zero other tests execute — nothing this +item added can interfere (identical test + production code as base). Filed as follow-up +`NET-FLAKY-01` in the df1 queue. + +## Residual gaps / honest limits + +- `settings`-internal unknown subkeys: dropped by BOTH servers (typed/`mergeServerSettings` + fixed-key normalization) — frozen parity, not a CFG-01 loss mode. +- Settings cross-PROCESS conflict (legacy edits `settings.defaultCwd` while Rust runs; + Rust's next patch overlays `settings` wholesale): the documented, accepted Batch-B + residual; CFG-02's serialization queue (not in flight) is the follow-up surface. +- Crash-MID-WRITE atomicity (torn tmp/rename legs) is CFG-11's acceptance, not this + item's; the destructive sandbox legs belong to that item. +- The PW spec runs the writer legs against REST endpoints with no live PTYs; terminal + rename's session-cascade and session rename's terminal-cascade are no-ops for unknown + IDs (by design of the store keys) — cascade parity itself is owned by SESSION-03/TERM-*. diff --git a/docs/plans/df1-evidence/CFG-04.md b/docs/plans/df1-evidence/CFG-04.md new file mode 100644 index 000000000..c99ea7274 --- /dev/null +++ b/docs/plans/df1-evidence/CFG-04.md @@ -0,0 +1,88 @@ +# CFG-04 — Restore automatic legacy browser-preference seeding — df1 evidence + +**Branch:** `df1/cfg-04-browser-seed` (base `origin/df1/integration` @ `4c2297667`) · **Date:** 2026-08-09 · **Playwright posture:** `deferred` + +IMPLEMENTED (2026-08-09, df1 worker `df1-cfg-04-browser-seed`): the Rust server now extracts, +merges, persists, and bootstrap-returns `legacyLocalSettingsSeed` with byte-fidelity to the +frozen legacy server (`server/config-store.ts` + `server/shell-bootstrap-router.ts` + +`shared/settings.ts` as parity source). The client consumption + one-time marker already +existed and are unchanged (proven pre-existing by the repo's own unit suites, re-run green on +this branch). + +- **Extraction/merge port** — `crates/freshell-server/src/legacy_local_seed.rs` (new). + `extract_legacy_local_settings_seed` + `merge_legacy_seeds` port + `extractLegacyLocalSettingsSeed`/`normalizeExtractedLocalSeed` and the seed half of + `mergeLocalSettings`: all five item categories (theme, browser-local sidebar presentation, + scale, terminal font, sound) plus panes/freshAgent/streamDeck for contract completeness; + enum drops, numeric clamps-with-rounding exactly where the legacy clamps, default-fills + (`sortMode`/`worktreeGrouping`, incl. `hybrid`→`activity` and null→default), the + `ignoreCodexSubagentSessions`→`ignoreCodexSubagents` alias (canonical-present always wins, + even when invalid), the `agentChat`→`freshAgent` per-key canonical-wins alias, and JS number + serialization (integral floats persist as `1`, never `1.0`) for byte-stable side-by-side + operation. 15 module tests byte-pinned against the REAL legacy functions executed via tsx + on the frozen base (oracle battery), several asserting byte-equality with + `JSON.stringify` output, not just value equality. +- **Boot wiring** — `crates/freshell-server/src/settings_store.rs`. `SettingsStore::load` + extracts/merges the seed AFTER the CFG-03 backup restore (so the recovered document is what + is read); the seed is held immutable for the process life (legacy cache parity), accessor + `legacy_local_settings_seed()`. `persist()` owns the top-level key: written from memory when + present, REMOVED when `None` (JS `JSON.stringify`-drops-`undefined` parity — never `null` on + disk). The boot normalization persist fires on the seed-scoped half of + `shouldPersistNormalizedConfig`: local keys found inside `settings` (stripped by the typed + tree), or merged seed ≠ raw stored key (incl. garbage/un-normalizable seed removal). Server + keys (`sidebar.excludeFirstChatSubstrings`/`excludeFirstChatMustStart`, the SESSION-13 + surface) remain in the typed tree and on disk — proven by test, untouched by design. +- **Bootstrap return** — `crates/freshell-server/src/boot.rs`. `GET /api/bootstrap` includes + `legacyLocalSettingsSeed` when (and only when) a seed exists, in the original's payload key + order (settings, seed, platform, shell, perf); absent, never `null`. Payload assembly + extracted to the pure, unit-tested `bootstrap_payload`. The seed remains bootstrap-only: + nothing added to `/api/settings`, WS snapshots, or `settings.updated` — the typed + `ServerSettings` cannot carry it by construction. + +**PROVEN (crate + unit level, all green twice where flaky-prone):** + +- `cargo test -p freshell-server` (all targets): 592 passed / 0 failed, at final SHA, two runs. + Includes 15 new `legacy_local_seed` fixture tests (byte-parity vs the Node oracle) and 7 new + `settings_store` integration tests: mixed-legacy boot extracts+strips+seeds all five + categories while `excludeFirstChat*` stay server-backed; boot persist writes the top-level + seed and strips local keys from `settings`; **second boot is byte-stable** (the seed + change-check converges — the one-time-marker server-side analog); stored-seed-wins merge + precedence with extracted strays preserved; seed survives an unrelated PATCH + (CFG-01-style losslessness for this writer); fresh installs never synthesize/write a seed; + garbage stored seeds (`"nope"`, `{"theme":"neon"}`) are removed from disk at boot. +- Focused legacy/client regression suites green (no TS changes were needed): + `config-store.test.ts` + `bootstrap-router.test.ts` (75/75, server config), + `browser-preferences.test.ts` + `browserPreferencesPersistence.test.ts` (20/20), + `App.test.tsx` + `terminal-font-settings.test.tsx` (36/36, incl. the four + `legacyLocalSettingsSeed` bootstrap-consumption tests and the + does-not-reapply-after-reset-to-default marker test). +- `cargo clippy -p freshell-server --all-targets -- -D warnings` clean; `cargo fmt --check` clean. + +**Playwright (deferred — authored, intentionally unrun by the worker):** + +- `test/e2e-browser/specs/cfg04-legacy-browser-seed.spec.ts` (new, matrix-registered in + `MATRIX_SPECS`) mirrors the checklist validation text exactly: pre-split mixed legacy + config + empty browser storage → open → every visible seeded preference asserted in resolved + settings (theme/scale/font/sidebar presentation/sound + exclusion retention) → blob holds the + seed with `legacyLocalSettingsSeedApplied: true` → reload ×2 still resolves → user change to + `dark` → reload → stale server seed (`light`) NOT re-applied (the one-time marker clause) → + disk assertions (seed top-level, local keys stripped, exclusions intact). One authoring-time + review fix landed pre-registration: `collapsed` was removed from the fixture because a + collapsed sidebar unmounts the sidebar Settings button the user-change step clicks + (`App.tsx`'s `{!sidebarCollapsed && }`) — `collapsed` remains covered at crate + level instead. +- `test/e2e-browser/specs/settings-persistence-split.spec.ts`: the rust-leg `test.fail` + annotation ("CFG-04/SESSION-13: legacyLocalSettingsSeed not implemented in Rust") is REMOVED + — the gap it pinned is what this item implemented; the spec comment now records the history. + SESSION-13's own replication/apply scope is unchanged and unaffected (this spec never + interacts with the exclusion knobs). +- Static parity check (no Playwright run allowed under `deferred`): own-file `tsc` strict + one-shot error count equals the sibling `settings-persistence-split.spec.ts` baseline (5==5, + identical classes — artifacts of running outside the repo tsconfigs, which intentionally + exclude `test/`). + +**MISSING (explicit, by campaign policy):** neither spec has been EXECUTED in this phase +(`spec-authored-unrun: test/e2e-browser/specs/cfg04-legacy-browser-seed.spec.ts`); the +close-out campaign's matrix pass is the executor, with the crate+legacy suite evidence above +as the interim proof. DIAG-07's bootstrap byte-budget remains unowned by this item (pre-existing +on Rust; unchanged). diff --git a/docs/plans/df1-evidence/CFG-12.md b/docs/plans/df1-evidence/CFG-12.md new file mode 100644 index 000000000..9727d40ed --- /dev/null +++ b/docs/plans/df1-evidence/CFG-12.md @@ -0,0 +1,143 @@ +# CFG-12 — Preserve the browser-local/server-wide settings split — df1 evidence + +**Branch:** `df1/cfg-12-settings-split` (base `origin/df1/integration` @ `3dbba43c2`) · **Date:** 2026-08-09 · **Item:** CFG-12 (checklist: `docs/plans/2026-07-14-rust-tauri-parity-completion-checklist.md` — two isolated contexts: browser-local theme/sidebar prefs stay per-profile; server-shared `defaultCwd` replicates to every client and persists). + +## Root cause + +The rust port emitted a **boot-frozen** `Arc` (`WsState.settings`, snapshotted in +`crates/freshell-server/src/main.rs` at boot) as EVERY `/ws` connection's handshake +`settings.updated` frame (`build_handshake_with_capabilities`, +`crates/freshell-ws/src/lib.rs`). The field's own doc comment admitted the divergence: the +original recomputes settings per connection (`server/index.ts:415-427` +`handshakeSnapshotProvider` → `await configStore.getSettings()`; `server/ws-handler.ts:1815-1845` +`sendHandshakeSnapshot`). A `PATCH /api/settings { defaultCwd }` committed the live +`SettingsStore`, persisted `config.json`, and broadcast a live frame to CONNECTED clients — but a +client that (re)loaded afterwards received the boot snapshot in its handshake, and the client's +last-write-wins `setServerSettings(msg.settings)` (`src/App.tsx:1151-1152`) erased the correct +value `/api/bootstrap` (already live: `boot.rs:104` reads `store.get().await`) had delivered. + +## Fix (commit `4a303bcfc`) + +- `WsState` gains `handshake_settings: Arc>` — the LIVE tree, + resolved per connection by the (now async) `build_handshake*` builders. +- `SettingsStore::shared_settings_lock()` vends the store's ONE inner lock (Arc identity: a PATCH + commit is exactly the memory the next handshake reads; no copies, no caching layer); `main.rs` + wires it into `WsState`. +- The frozen `settings` field REMAINS boot-scoped for `terminal.rs`'s create-time derivations — + CFG-06's boundary ("every new operation resolves live"), pinned by an explicit assertion in the + new unit test so the two fields cannot be silently merged without CFG-06's per-consumer proofs. +- Clean-boot wire bytes are unchanged (the lock is seeded from the same loaded tree); oracle + byte-parity fixtures untouched and passing. + +## RED proofs (pre-fix code) + +1. **Unit compile-REDs:** `cargo test -p freshell-ws --lib handshake_settings_updated_reflects_live` + → `E0609: no field handshake_settings` / `E0277: Vec is not a future`; + `cargo test -p freshell-server settings_store::tests::patch_is_visible` → + `E0599: no method shared_settings_lock`. +2. **Unit assertion-RED** (scaffolding landed, builder still frozen): + `tests::handshake_settings_updated_reflects_live_writes_between_connections` FAILED — + `left: Null, right: String("/tmp/shared-cwd")` ("a later connection's handshake must resolve the + live tree, not the boot snapshot"); 430 other ws lib tests passed. +3. **E2E annotation-RED** (pre-fix binary `target/release/freshell-server` → copied to + `/tmp/opencode/freshell-server-cfg12-prefix`, startup line `[commit + 3407b3d20212d0e6b1affb4c110584e1222767b1] [dirty false]` — docs-only commit over base + `3dbba43c2`, i.e. pre-product-change): + + `FRESHELL_E2E_RUST_SERVER_BIN=/tmp/opencode/freshell-server-cfg12-prefix npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium --reporter=json test/e2e-browser/specs/settings-persistence-split.spec.ts` + + → defaultCwd test: annotation + `CFG-12: rust WS/bootstrap settings resolution drops a PATCHed server-shared defaultCwd (2026-08-09)`, + `expectedStatus: "failed"`, actual `status: "failed"` at spec line 203: + Expected `"/tmp/freshell-e2e-rust-xMNnLz/shared-default-cwd"`, Received `undefined` + (10s `expect.poll` predicate timeout after `pageB.reload()`). Seed test passed as expected; + suite stats `expected: 2, unexpected: 0`. `patchResponse.ok` passed beforehand — the PATCH was + accepted; the red edge is strictly client-visible replication (matches JAN-87's triage). + +## GREEN proofs + +### Rust unit / integration (post-fix, cargo lease) + +- `cargo test -p freshell-ws --lib` → **431 passed, 0 failed** (incl. new + `handshake_settings_updated_reflects_live_writes_between_connections` + all 5 pre-existing + handshake-shape tests re-`#[tokio::test]`-ed). +- `cargo test -p freshell-server settings_store::tests` → **57 passed, 0 failed** (incl. new + `patch_is_visible_through_shared_settings_lock` (Arc identity) and + `patched_default_cwd_survives_reload_from_disk` (restart half of the checklist text)). +- `cargo test -p freshell-ws --test handshake_live_settings` → **1 passed** (NEW: real `/ws` + server, two connections, lock mutation between → 2nd handshake carries `defaultCwd`). +- `cargo test -p freshell-server --bin freshell-server` → **610 passed, 0 failed** (full bin suite). +- `cargo test -p freshell-ws --all-targets` → first full run: 1 failure in + `codex_locator_activity::fresh_pane_locator_identity_reaches_activity_and_turn_complete` + (turn-complete timing test, hit its window under swarm load, 35.4s; NOTHING settings-adjacent). + Isolated rerun `cargo test -p freshell-ws --test codex_locator_activity` → **ok (5.4s)** — + classified pre-existing load flake, not a regression from this diff. (Full-suite rerun result + recorded below.) +- `cargo fmt --check` clean. + +### Playwright (pw lease; spec un-pinned) + +Post-fix binary: `cargo build --release -p freshell-server` in-worktree (the rust-chromium +fixture's `ensureRustServerBuilt` no-op rebuild check then runs against a warm target dir). + +At the fix commit with the spec un-pinned in-tree (pre-commit worktree state of `cf3764707`): + +- `--project=rust-chromium` run 1: **2 passed** (19.3s) — defaultCwd test now passes + un-annotated (its Playwright annotation list is EMPTY; the deleted pin would have hard-failed + an unexpected pass, so green here is direct proof the pin was correctly removed). +- `--project=rust-chromium` run 2: **2 passed** (21.1s). +- `--project=legacy-chromium` run 1: **2 passed** (20.5s). +- `--project=legacy-chromium` run 2: **2 passed** (43.4s). + +At the code-final SHA `cf3764707` (after the comment-only clippy fix; binary rebuilt, 45.2s — +HEAD at report time is `91beabfeb`, a docs-only evidence commit on top of `cf3764707`): + +- `--project=rust-chromium` run 1: **2 passed** (16.7s). +- `--project=rust-chromium` run 2: **2 passed** (17.9s). +- `--project=legacy-chromium` run 1: **2 passed** (19.3s). +- `--project=legacy-chromium` run 2: **2 passed** (26.3s). + +Focused cargo rerun at final SHA (same PTY chain as the build): ws lib **431/431**, +`--test handshake_live_settings` **1/1**, server bin **610/610**. `cargo fmt --check` clean; +`cargo clippy -p freshell-ws -p freshell-server --all-targets -- -D warnings` clean (round 2, +after rewording one doc-comment line that tripped `doc_lazy_continuation`); `npm run typecheck` +clean. + +## Review record + +Structured fresh-eyes self-review per the review-agent protocol (no `Task` tool in this +environment → the orchestrator's sanctioned fallback), over `git diff 3dbba43c2..cf3764707` +(the full change, incl. all 37 files): + +- Verified no missed `WsState` construction sites: compiler-checked (`cargo check --all-targets`) + + full green suites; 5 src + 8 common/mod.rs + 26 per-file integration literals all carry the + new field. +- Verified no torn/interleaved read is possible through the handshake lock: `SettingsStore::patch` + holds the write guard only for in-memory merge, drops it BEFORE disk `persist()`, commits the + fully-merged tree with a second short write (`settings_store.rs:377-416`), so a handshake read + sees a complete old-or-new tree and never waits on disk IO. +- Verified clean-boot byte parity claim by test, not inspection alone: all 5 pre-existing + handshake-shape/transcript tests re-run green under the async builder; oracle fixture test + (`default_plus_network_overlay_matches_captured_fixture`) green (610-pass bin suite). +- Verified the CFG-06 boundary is pinned behaviorally (frozen view must NOT follow the live lock — + explicit assertion inside the new ws lib test). +- Spec edit: `e2eServerKind` removed from the second test's destructure (no remaining use); + typecheck clean; both pw legs green ×2 after the edit. + +**Findings: none.** Residual risks (accepted, owned elsewhere): create-time consumers still read +the boot-frozen view — deliberately deferred to CFG-06 (its PW validation asserts exactly that); +the checklist sentence's rust-restart leg is proven at store level +(`patched_default_cwd_survives_reload_from_disk`) plus the spec's on-disk `config.settings +.defaultCwd` assertion, matching the campaign acceptance, which names the exact split-spec legs. + +## Commands (verbatim, at final SHA) + +``` +# unit + wire +cargo test -p freshell-ws --lib +cargo test -p freshell-server --bin freshell-server +cargo test -p freshell-ws --test handshake_live_settings +# e2e (pw lease) +npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium test/e2e-browser/specs/settings-persistence-split.spec.ts +npx playwright test --config test/e2e-browser/playwright.config.ts --project=legacy-chromium test/e2e-browser/specs/settings-persistence-split.spec.ts +``` diff --git a/docs/plans/df1-evidence/DIAG-01.md b/docs/plans/df1-evidence/DIAG-01.md new file mode 100644 index 000000000..ce9108857 --- /dev/null +++ b/docs/plans/df1-evidence/DIAG-01.md @@ -0,0 +1,87 @@ +# DIAG-01 — Structured JSONL Rust server/Tauri logs — Evidence + +Worker: `df1-diag-01-jsonl-logs` · Branch: `df1/diag-01-jsonl-logs` (base `origin/df1/integration` @ `3dbba43c2`) · Plan: `docs/plans/df1/DIAG-01.md` + +## Verdict + +**Rust-server side: COMPLETE.** Every JSONL line carries the full DIAG-01 required set — timestamp, severity, component/event, request/connection/process ownership, app version, lifecycle context — proven black-box against the compiled binary through the checklist's named flows (auth, terminal, provider, recoverable error, restart, quit). Tauri-side producers are host-limited (dispatch-sanctioned); the schema is documented as a Tauri-ready contract. + +## What the base already had (credit where due) + +- `d5a526d3` — `crates/freshell-server/src/logging.rs`: JsonLayer (`ts`/`level`/`target`/`msg` + flattened fields), rotation, from-first-byte redaction, HTTP request middleware (`request_id`/`route`/`method`/`status`/`duration_ms`). +- `c200a656c` — WS (`ws.connection.established/closed/hello.rejected/keepalive.terminated`), terminal (`terminal.created/exited/killed/idle_reap`), freshagent-codex lifecycle events as event-level fields. +- `shutdown_forensics` record at signal receipt. + +## Gaps closed by this branch + +1. **App version was absent from log output entirely** → `app_version` now stamped on every line by the JsonLayer (resolved once at boot: `FRESHELL_APP_VERSION` env → `APP_VERSION` const; cross-checked against `GET /api/version`'s `currentVersion` in the black-box test). (commit `5cea64611`) +2. **Process ownership per line** → `server_pid` stamped on every line next to `app_version` (child processes keep their own `pid` fields on spawn events — no collision by name). (commit `5cea64611`) +3. **Connection ownership existed only on two lifecycle events** → `run()` now wraps the whole serve loop (extracted `run_loop`) in a per-connection `ws_conn` span (`connection_id` + `origin_kind`); all 15 fresh-agent `tokio::spawn` sites in the dispatch are `.instrument(Span::current())`'d, the gated restore-create task too, and all 16 `spawn_blocking` sites in `terminal.rs` go through `spawn_blocking_in_span` (span context would otherwise be dropped at BOTH thread/task boundaries — this is what puts `connection_id` on the registry's `terminal.created`, which fires from a blocking-pool PTY spawn). (commit `f787178fa`) +4. **No server lifecycle context** → `server.started` (`bind`,`port`,`boot_id`,`instance_id`,`commit`,`dirty`), `server.stopping` (`signal`), `server.stopped` (after every shutdown owner; on disk by construction — synchronous per-line flush). `boot_id` wiring into `WsState` became a clone so the event names the same boot the WS handshake reports. (commit `3ceb699df`) +5. **Stale module doc** claiming WS/terminal wiring out of scope → rewritten as the canonical schema contract. (commit `3ceb699df`) +6. **Review-round-1 fix**: `ws_conn` span was INFO-level → silently disabled by an operator's `RUST_LOG=warn`/`error`, stripping `connection_id` from exactly the WARN/ERROR in-connection events an operator cranks the filter up to inspect (empirically confirmed: empty span-field set under a warn EnvFilter). Span now minted at ERROR level via one production constructor `connection_span` (context infrastructure; JsonLayer never renders span open/close so zero output effect), pinned by a five-level filter matrix unit test with an INFO-flip negative control. (commit `effa0421d`) +7. **Review-round-1 fix (doc)**: plan flow list closed the WS then reused it; corrected to one socket across steps 2–4. (commit `effa0421d`) + +## Live sample (debug binary, temp HOME, real HTTP + SIGTERM, 2026-08-09) + +```json +{"ts":"2026-08-09T23:20:05.893Z","level":"INFO","target":"freshell_server","app_version":"0.7.0","server_pid":439322,"bind":"127.0.0.1","port":19817,"boot_id":"boot-…","instance_id":"srv-…","commit":"f787178fab…","dirty":"true","msg":"server.started"} +{"ts":"2026-08-09T23:20:06.085Z","level":"INFO","target":"freshell_server","app_version":"0.7.0","server_pid":439322,"signal":"SIGTERM","msg":"server.stopping"} +{"ts":"2026-08-09T23:20:06.638Z","level":"INFO","target":"freshell_server","app_version":"0.7.0","server_pid":439322,"msg":"server.stopped"} +``` +Exit code 0; zero occurrences of the live AUTH_TOKEN in the file. + +## Tauri-readiness note (host-limited producers) + +The canonical schema is documented in `crates/freshell-server/src/logging.rs`'s module header ("Canonical line schema (the Tauri-ready contract)"). It is deliberately free of server-only assumptions: a Tauri host producer emits the same shape with its own `app_version` and its process's pid, and the streams merge coherently. Implementing the Tauri-side producer requires the native Windows/macOS Tauri host (`PW-TAURI-WIN`/HARNESS-07), unavailable on this Linux host per the dispatch brief; nothing in this branch blocks it. + +## Playwright decision (dispatch: "prefer rust crate/integration tests, record decision") + +No Playwright spec was authored. Rationale: DIAG-01's assertion surface is a server-side file artifact. The black-box Rust integration test (`crates/freshell-server/tests/diag01_lifecycle_logging.rs`) boots the REAL compiled binary and drives REAL HTTP + WS + SIGTERM with deterministic `HOME`/`FRESHELL_HOME` temp isolation — strictly more direct than a browser-mediated run of the same flows, and it can drive SIGTERM/restart, which a browser cannot. The checklist's named flows (auth, terminal, provider, recoverable error, restart, quit) are all exercised there. `PW-TAURI-WIN` remains blocked on the native Tauri host regardless of test framework. + +## Green commands (final HEAD) + +All run under the df1 cargo lease (`acquire.sh cargo df1-diag-01-jsonl-logs`): + +- `cargo test -p freshell-server --test diag01_lifecycle_logging` — 2/2 passed (×3 runs) +- `cargo test -p freshell-server --test diag01_diag03_logging` — 1/1 passed +- `cargo test -p freshell-server` — full crate: 609 unit + all integration files green +- `cargo test -p freshell-ws --lib` — 433 passed, 0 failed (includes the new span/dedupe tests) +- `cargo test -p freshell-ws` — every binary green EXCEPT `tests/auto_resume_e2e.rs`, which flakes on this host at 10s frame-wait timeouts; attribution-tested against virgin base (fails there at the same-or-worse rate) = PRE-EXISTING, not caused by this branch — see "Known-flaky exclusion" below +- `cargo test -p freshell-terminal` — 175+ green +- `cargo test -p freshell-freshagent` — 358+ green (incl. the extended diag01 capture test) +- `cargo clippy -p freshell-server -p freshell-ws -p freshell-freshagent --all-targets -- -D warnings` — clean +- `cargo fmt --check -p freshell-server -p freshell-ws -p freshell-freshagent` — clean + +## Precise waiter-window note (review round 4 finding, dispositioned) + +`CreateDedupe` purposefully answers a cross-connection duplicate arriving during an *adopt* / *session-ref-attach* / *session-reserved* window with a fail-loud error (the origin settles nothing on those exits; the sentinel is dropped "on every other exit (adopt/session-reserved/sessionRef-attach/create failure, restore-gate shutdown)" — deliberate per the code's own design comments, predating this branch and untouched by it). On those windows the `ws.terminal.create.settled` event logs the ORIGIN's reply with `path="adopted"` / `"session_ref_attached"`; the waiting connection receives no `terminal.created` (by design, loud instead). Whether the adopt window should instead settle-and-forward is a create-lane semantics question, not a logging question — filed as `DIAG-FOLLOWUP-ADOPT-WAITER-SETTLE` for that lane's owner. + +## Known-flaky exclusion (classified, attribution-tested) + +`crates/freshell-ws --test auto_resume_e2e` (both tests) flakes on this host at 10s frame-wait timeouts. Attribution experiment: virgin `origin/df1/integration` in a scratch worktree fails **3/6 isolated runs (50%)** once `node_modules` exists (without it: 100%, because claude-mode MCP injection resolves `node_modules/tsx`/`dist/server/mcp/server.js` relative to repo root — also a fail-loud gap); this branch: 4/17 (~24%). Same failure on both → pre-existing, NOT caused by DIAG-01. Filed as `DIAG-FOLLOWUP-AUTORESUME-FLAKE`. + +## Review loop record + +1. Preferred path (fresh subagent via freshell fresh-agent pane) attempted twice; the environment's MCP gateway timed out / returned phantom tab ids → fell back to the dispatch-sanctioned recorded fresh-eyes review. +2. Round 1 (fresheyes `--gpt`, FRESHPID 236685): 2 findings — (a) INFO-level ws_conn span drops connection_id under `RUST_LOG=warn+` (empirically confirmed, fixed with ERROR-level `connection_span` + 5-level filter test + negative control); (b) plan-doc WS close/reuse contradiction (fixed). Commit `effa0421d`. +3. Round 2 (FRESHPID 2576086): 5 findings — (a) target-directive filters: probed the real span-enablement matrix (bare levels + globally-anchored mixes keep spans; target-directive-only mixes kill span callsites outright, even matched-target ones) → systemic dual-carrier fix (`ws.terminal.create.settled` event-field join from all four reply paths) + honest guarantee-envelope test + schema doc; (b) plan Task-2 code still showed `info_span!` → fixed; (c) evidence-untracked (stale mid-review read; already committed) → no-op; (d) plan run-steps missing lease wrapper note → fixed; (e) ambient `FRESHELL_APP_VERSION` could poison the restart test → `env_remove` added. Commit `6bc8cb371`. +4. Round 3 (FRESHPID 4076954): 2 findings — (a) cross-connection in-flight duplicate waiters answered via bare FrameSink: no join could name the waiter's connection → waiters now carry `conn_id` through `CreateDedupe` and `settle()` emits one `ws.terminal.create.settled` (path `duplicate_in_flight_waiter`) per waiter (RED first: frame forwarded, no event); (b) schema overclaimed `freshagent.sidecar.spawned` fields → event now carries static `provider` + `pid`, doc spells out the pid↔session adjacent-event join, black-box test asserts both. Commit `832f575f5`. +5. Round 4 (FRESHPID 2661566): 4 findings — (a) adopt/session-ref windows never settle dedupe (waiters get fail-loud error): verified DELIBERATE pre-existing semantics (documented in the dispatch's own comment, predates this branch); dispositioned as precise documentation + filed `DIAG-FOLLOWUP-ADOPT-WAITER-SETTLE` for the create lane (delivery semantics out of a logging item's scope); (b) `crash_detected` lacked `provider` → provider swept onto all freshagent session lifecycle events (incl. `send.accepted`, `turn.complete`, `crash_recovery.*`), in-crate test extended (RED first); (c) plan Produces line stale → fixed; (d) evidence wording now carries the flake exception in-sentence. Commit `6d241f4ff`. +6. Round 5 (FRESHPID 4060204): 3 findings — (a) `crash_recovery.minted_new` lacked canonical `session_id` → added (new current thread) beside the old/new forensics pair; (b) black-box boot didn't clear `FRESHELL_LOG_DIR`/`MAX_BYTES`/`MAX_BACKUPS` → `env_remove` all three; (c) `ws.connection.closed` relied on span-only `origin_kind` → now an event field (proven via the capture layer's new event-only `event_fields` view; RED via that view). Commit `30b1388e9`. +7. Post-round-5 structured self-review (recorded; loop capped at 5): re-read the full final diff (`git diff origin/df1/integration...HEAD`) finding: no further qualifying defects; dispositions consistent; schema doc matches code (spot-checked every documented field against its producer site: `terminal.killed` by/api-idle-shutdown ✓, `shutdown_forensics` event field ✓, settle-companion fields ✓). One observed-transient disclosure: one freshell-freshagent lib test failed exactly once during the r5 verification window (name not captured); 7/7 full-suite reruns immediately after were green, and the r5 diff does not touch timing logic — classified unclassified-transient, noting here for the verifier. + +Final HEAD fully green: freshell-ws 44/44 binaries (including auto_resume_e2e), freshell-server, freshell-terminal, freshell-freshagent (358), clippy `-D warnings` clean, fmt clean. + +## Load-bearing audit (all VERIFIED before execution) + +| Claim | Method | Result | +|---|---|---| +| No tracing call site uses `app_version`/`server_pid` field names | `rg` across all server crates | zero matches | +| `connection_id` is `u64` | registry.rs:754 | ✓ | +| `.instrument(Span::current())` propagates into spawned tasks | standard tracing idiom + in-crate test | ✓ (test passes; negative control fails) | +| In-crate WS harness can drive real terminal.create | `create_protection.rs` precedent | ✓ | +| `app_version` resolution movable before logging init | main.rs read (pure env+const) | ✓ compiles/works | +| `CODEX_CMD="node "` reaches the spawned server | safe11 test:271 + codex.rs:1978 | ✓ | +| Writer flush is synchronous per line | logging.rs write_line + unit tests | ✓ | +| DIAG-04's app_version surfaces don't collide | diag.rs is API responses, not logs | ✓ | diff --git a/docs/plans/df1-evidence/EXT-01.md b/docs/plans/df1-evidence/EXT-01.md new file mode 100644 index 000000000..1335cede9 --- /dev/null +++ b/docs/plans/df1-evidence/EXT-01.md @@ -0,0 +1,105 @@ +# EXT-01 — Evidence: Port the complete strict manifest schema + +**Item:** EXT-01 — Port the complete strict manifest schema (P1 — Extensions). +**Worker:** df1-ext-01-manifest-schema, worktree `.worktrees/df1-ext-01-manifest-schema`, branch `df1/ext-01-manifest-schema` (base: `origin/df1/integration` @ `3dbba43c2`). +**Verdict:** PASS. Independent defect-first review (fresheyes, claude provider, FRESHPID 2196947) found 1 major + 8 minor/nit findings; ALL majors/minors fixed and re-verified, nits recorded. Review loop iterations used: 2 of ≤5 (gpt run died on SIGPIPE 141 infrastructure failure before producing findings; claude run completed). +**Plan:** `docs/plans/df1/EXT-01.md` (committed; contains the full design-call record DC-1…DC-7 incl. review-driven amendments DC-4.11/4.12, DC-5). + +## What landed + +1. **New crate `crates/freshell-extensions`** — the complete strict manifest validator: + - `src/manifest.rs` — typed model (`ExtensionManifest`, `ClientConfig`, `ServerConfig` with materialized `args=[]`/`readyTimeout=10000`/`singleton=true`, full 15-field `CliConfig`, `PickerConfig`, `TerminalBehavior` with single/two-option enums, `ContentSchemaField` + `DefaultValue` union with JS-double-faithful canonicalization) with output-side `Serialize` reproducing zod's `result.data` shape. No `Deserialize` impls: the typed model is obtainable ONLY through validation. + - `src/validate.rs` — hand-written `serde_json::Value` walker: strict unknown-key rejection at every object level; category↔config-block refine; content-schema field typeof-refine; zod-4 refine-gating abort rule; definition-order issue emission with `unrecognized_keys` last; JS-safe-int positive `readyTimeout` with accumulating checks; null-vs-absent `.optional()`; `__proto__` record-skip; JS own-key enumeration order; byte-exact zod 4.3.6 `(code, path, message)` triples. + - `src/issue.rs` — the issue model (codes, path segments, `ManifestError` with legacy's two log classes). + - `src/validate/tests.rs` — 10 focused unit tests (error-class split, log Display, error paths; own-key ORDER text fidelity incl. numeric keys; `__proto__` drop/reject asymmetry; union single-issue shape; full CLI surface round-trip; typeof-name coupling pin). + - `tests/oracle.rs` + `fixtures/manifest-oracle.json` — **130-case differential oracle** (43 valid / 86 schema-invalid / 1 invalid-JSON-text) generated from the UNMODIFIED legacy schema. Comparator `js_value_eq` implements JS-double semantics (serde Number equality is variant-strict). +2. **Oracle generator** `port/contract/generate-manifest-oracle.ts` — runs the real zod schema over the pinned case list (all 35 legacy vitest cases, all 6 bundled manifests as raw text, duplicate-key/raw-text rows, every probed zod-4 semantic, 6 review-driven rows). Hard-refuses to generate if installed zod ≠ package-lock.json pin. Hermetic: byte-identical regeneration. Listed in `port/contract/README.md`. +3. **`crates/freshell-server/src/extensions.rs` rewired** to consume the crate: strict rejection with legacy's two warn lines (asserted by a new global-subscriber capture test, parallel-safe per the repo's documented pattern), icon gate corrected to legacy truthiness, UTF-8-lossy read matching `fs.readFileSync(…, 'utf-8')`, frozen public surface unchanged. + +## Parity coverage vs the checklist keywords + +| Checklist word | Where proven | +|---|---| +| client/server/CLI category requirements | oracle rows `server-category-without-server-block`, `client-category-without-client-block`, `cli-category-without-cli-block`, `server-category-with-extra-client-block`, `cli-category-with-all-three-blocks`, all refine-gating rows; server tests `scan_skips_category_block_mismatch_and_missing_blocks`, `scan_skips_client_and_server_manifests_without_their_blocks` | +| defaults | oracle rows `server-defaults-materialize`, `server-args-default-empty`, `cli-args-default-empty` assert output data `args:[]`, `readyTimeout:10000`, `singleton:true` | +| timeouts | oracle rows `server-readytimeout-{negative,zero,non-integer,negative-non-integer,below-safe-int,above-safe-int,text-beyond-2e53-rounds,wrong-type-string,max-safe-int}` | +| capabilities | `supportsPermissionMode`/`supportsModel`/`supportsSandbox` wrong-type rows + full-template row; registry shape tests | +| content schema | field-type rows, union default rows, typeof-refine rows, unknown-key row, `field-refine-gated-by-aborting-member`; order fidelity unit tests | +| icons | `empty-icon-string-valid` ("" valid) + `icon-null-rejected`; server test `empty_string_icon_produces_no_icon_url` | +| commands | command min(1)/type/absence rows for server + cli | +| create/resume identity | `cli-full-launch-templates-and-permission-mapping`, `cli-resumeargs-non-string-element`; registry `resumeCommandTemplate` shape tests | +| models | modelArgs row, `cli-supportsmodel-wrong-type` | +| sandbox | sandboxArgs row, `cli-supportssandbox-wrong-type` | +| permissions | permissionModeArgs/EnvVar/Values rows incl. wrong-type pins | +| unknown fields | `unknown-*` rows at all 7 object levels incl. pluralization + position pins | + +## Load-bearing ledger (all VERIFIED by running code; method = direct execution against the lock-pinned zod) + +| ID | Assumption | Status | Evidence | +|---|---|---|---| +| LB-1 | zod 4.3.6 issue codes/messages/ordering/gating semantics | VERIFIED | probe batches against `npx tsx` + oracle fixture (130 rows) | +| LB-2 | Only `freshell-server/src/extensions.rs` parses freshell.json in Rust land | VERIFIED | `grep -rn freshell.json crates/` — 5 hits, 4 comments | +| LB-3 | All 6 bundled manifests pass the strict schema | VERIFIED | tsx run over legacy schema (all VALID) + server test `all_bundled_manifests_validate_and_register_through_scan` | +| LB-4 | tsx runnable in-worktree for the generator | VERIFIED | node_modules/.bin/tsx, devDep ^4.19.2 | +| LB-5 | serde_json `preserve_order` workspace-enabled | VERIFIED | root Cargo.toml; IndexMap order tests green | +| LB-6 | Duplicate JSON keys: last-wins both sides | VERIFIED | JS probe `{"a":1,"a":2}`→`{"a":2}`; oracle row `duplicate-name-key-last-wins` passes in Rust | +| LB-7 | Integers >2^53: serde u64 vs JS IEEE rounding — verdict-identical | VERIFIED | oracle rows `server-readytimeout-{above-safe-int,below-safe-int,text-beyond-2e53-rounds,max-safe-int}` pass | +| LB-8 | Existing extensions.rs tests encode the frozen registry shape | VERIFIED | all 8 pre-existing tests stay green post-rewire | + +**Falsified mid-flight (corrected):** "vendored zod is 4.4.3" — the main checkout's node_modules is dirty; the lock pin is 4.3.6. All probes were re-issued and the oracle generated against 4.3.6; the plan + this file reflect that. + +## Recorded divergences (deliberate, behavior-preserving) + +1. **Log shape:** legacy logs `result.error.format()` (nested object); Rust logs the flat issue list `?issues` next to legacy's message text. Content-equal, shape flattened. No client-visible surface carries validation errors (verified: `extension-routes.ts` only 404/400s lookups). +2. **Huge-magnitude number cosmetics:** content-schema defaults with |x| ≥ 1e21 re-serialize in exponent-form slightly differently than `JSON.stringify` ("1e21" vs "1e+21"); parsed-value equality holds. Integral f64 defaults in ±2^53 are canonicalized to ints, matching JS's no-`.0` output exactly. +3. **Pre-existing, kept intentionally:** subdirectory sort order for stable client arrays (documented in the module since Follow-up 3.19). + +## Test discipline + +- Scoped cargo only, cargo lease held (`acquire.sh cargo df1-ext-01-manifest-schema`). +- No npm test/check/verify, no un-scoped runs, no sandbox-needing destructive paths, no processes started (pure library + in-memory tests; temp dirs under `/tmp` per existing test helpers). +- Only files outside `crates/`: `port/contract/generate-manifest-oracle.ts` (new tooling; legacy `server/` untouched — verified by diff below) and docs. + +## GREEN COMMANDS (verbatim, at final SHA) + +``` +cargo test -p freshell-extensions +cargo test -p freshell-server +cargo clippy -p freshell-extensions -p freshell-server --all-targets -- -D warnings +cargo fmt --check +``` + +Results at final SHA (see header/git log): +- `freshell-extensions`: 10 unit + 1 oracle (130 cases) green — 3 consecutive runs. +- `freshell-server`: full crate suite **614 passed, 0 failed, 1 ignored** (plus harness binaries green) — 3 consecutive runs; not flaky (hermetic; only fs use is per-test unique temp dirs). +- clippy `-D warnings`: clean. fmt: clean. + +## Legacy-source integrity check + +`git diff origin/df1/integration...HEAD --stat -- server/ shared/ test/` → EMPTY (no legacy source or legacy tests touched). The oracle derives from `server/extension-manifest.ts` read-only. + +## Playwright posture + +Queue item `pwMode: null`; the checklist's PW-RUST line describes seeding manifests + registry assertions with no named spec file. Per dispatch: crate tests carry the proof. The registry-level behavior (only valid extensions appear; every registry field matches fixtures; invalid manifests produce logged diagnostics without disturbing discovery) is pinned by the 7 new server scan/warn tests + the 130-case oracle. No pw lease taken. + +## Review record + +1. **Structured fresh-eyes self-review** (recorded): 34-point adversarial checklist over the diff; found and fixed 2 parity gaps (`field-refine-gated-by-aborting-member` oracle row; `from_utf8_lossy` read semantics) + 1 panic-hazard class found pre-commit during implementation (unguarded `opt_out` in block closures — eliminated via `bad!()` guards before first run) + 1 doc drift. +2. **Independent fresheyes review** (FRESHPID 1440188, gpt provider): DIED on infrastructure failure (exit 141 SIGPIPE, `runner_state: failed`, no findings produced) — retried on the claude provider per the skill's fallback rule. +3. **Independent fresheyes review** (FRESHPID 2196947, claude provider): completed, verdict **FAILED** with a real defect. Cross-checked the port against the vendored zod 4.3.6 SOURCE (`node_modules/zod/v4/core/*.js`), not just behavior. Findings and dispositions: + - **[major] `__proto__` skipped by `z.record`** → accept/reject flip. VERIFIED by direct zod probe; FIXED (record walkers skip `__proto__`, strict objects still reject it) + 3 oracle rows + unit test. + - **[minor] >2^53 integer defaults stayed u64-exact** (JS rounds). VERIFIED; FIXED (always-through-f64 canonicalization; typed value now bit-identical to JS's double) + 2 oracle rows. + - **[minor] JS own-key order** (array-index keys first) not replicated. VERIFIED; FIXED (`js_ordered_keys` used by `unrecognized()` + both record walkers) + 1 oracle row (message text pin) + 2 order unit tests. + - **[minor] oracle version pin advisory-only.** VERIFIED (this bit me mid-task); FIXED (generator hard-refuses on lock mismatch; crate test asserts exact `4.3.6`). + - **[minor] scan warn lines untested.** FIXED (global-subscriber capture test; thread-local was nondeterministic under `cargo test` — resolved per the repo's documented OnceLock pattern; 6× parallel re-runs green). + - **[minor] plan doc drift** (stale "4.4.3" labels, case counts, unchecked boxes, silently-replaced acceptance criterion, wrong NaN/Infinity reasoning). FIXED in `docs/plans/df1/EXT-01.md` (incl. DC-4.10 correction: `1e400` → JSON.parse yields `Infinity` → legacy warns 'invalid manifest', we warn 'invalid JSON in manifest' — verdict parity holds, warn CLASS diverges; accepted residual). + - **[minor] evidence file untracked + self-contradictory.** FIXED (this file, committed, verdict consistent). + - **[nit] generator `as const`, README discoverability, commit-splitting, redundant oracle row.** First two FIXED; commit history and the one redundant row left as-is (cosmetic; regenerating to drop a row churns the fixture for no behavioral gain). +4. **Unavailability note (per dispatch fallback rules):** no Task tool in this environment; the freshell fresh-agent review pane (ext01-review) never materialized a reachable tab (production server answered "no tabs"; no orphan terminal left — verified via list-terminals). Self-review + independent CLI-driven review were used instead; both recorded here. + +## Fixture/regeneration discipline + +- `npx tsx port/contract/generate-manifest-oracle.ts` — byte-identical output on re-run (sha256-stable), and hard-fails on zod/lock drift. +- NEVER hand-edit the fixture to match Rust; the legacy schema is the only oracle. + +OUTCOME: COMPLETED — see verdict at top. diff --git a/docs/plans/df1-evidence/FINAL-WRAP.md b/docs/plans/df1-evidence/FINAL-WRAP.md new file mode 100644 index 000000000..05d87df7d --- /dev/null +++ b/docs/plans/df1-evidence/FINAL-WRAP.md @@ -0,0 +1,42 @@ +# DF1 Final Wrap — Fitness Record (wave 1 + wrap batch) + +Integration span: `4c2297667` (fork) .. `5dece6822` (tip). Branch `df1/integration`, worktree `.worktrees/df1-gate`. +22 items shipped: 18 strategic-wave items (A + B003 + B004 batches) + 4 wrap-batch items (JAN-88, RESTORE-01, SESSION-13, CFG-01). + +## Batch gates (all PASS before merge) +- Batch A (8 items) — `docs/plans/GO-A-HANDOFF.md` + per-item `docs/plans/df1-evidence/*-GO.md` +- B003 (8 items) — `docs/plans/df1-evidence/B003-HANDOFF.md` +- B004 wave-1 greens (CFG-04, BROWSER-01) — `docs/plans/df1-evidence/B004-HANDOFF.md` +- B005 wrap batch — `docs/plans/df1-evidence/B005-WRAP.md` + `B005-HANDOFF.md` + +## Second verification (wrap phase) +- B004-trusted items re-verified: 4/4 PASS on pure rerun (CFG-04 incl. allowed multi-client carve-out). +- Wrap items verified by fresh verifiers: JAN-88, RESTORE-01, SESSION-13, CFG-01 — 4/4 PASS, exact claimed commands re-run at attested heads. + +## Fresh-eyes wrap review (external, `--gpt`, `docs/plans/df1-evidence/WRAP-REVIEW.md`) +- 6 rounds, 14 majors fixed (r1: 6, r2: 3, r3: 3, r4: 2, r5–r6: 0 genuine majors). Verdict trail F/F/F/F/F/F — terminal-round F carries only re-rejected (immutable-history / by-design) findings. +- Highest-value catch (r3): `8b13d83a6` — loopback proxy forwarded `x-auth-token` + `freshell-auth` cookie verbatim to proxied apps (both servers); stripped with rust + legacy + browser01 pins. +- Other notable: dedupe-settle duplicate-PTY (r2 `c00630fec`, r3 `b7b3da712`), e2e-cloud portability/arg-corruption/stale-image class (r1/r3/r4). + +## Final gates +- R1 @ `36b7e09b4` (df1-gate-final): typecheck PASS; cargo 115 suites / 2999 passed; harness pins 6/6; significance sweep clean (delta-introduced `.only`/`todo` none; net-new conditional skips OK). Stopped on deterministic reds → arbitrations below. NOTE: `npm run test:cloud`/cloud-vitest does not exist at this rev (doc drift); cloud coverage is `test:e2e:cloud` (see R2). +- R2 @ `aa9f4b7b3`: typecheck PASS; cargo sanity (settings_store 71/71, settings 76/76, session_directory 60/60, net09 1/1); npm test PASS (client 440 files/4917 tests, server 319/4926, electron 34/350, exit 0); 52/52 harness 03/04/05/06/14 (+h11 chromium); wrap-item specs all green (incl. a11y deny-gate, helpers 268/269 minus posture artifact); product matrix ×3 legs green across project-colors/layout-sync*/browser01-proxy/settings-persistence-split/session-malformed-data/leak-metrics; `test:e2e:cloud` orchestrated end-to-end (589p/5flaky/25f remote). +- Post-R2 fix: `5dece6822` layout-sync chromium leg (spec race vs client LWW sync stomps; forced-stomp TDD proof; full spec green ×3 projects at tip). + +## Arbitrations (known reds, adjudicated) +1. **restore-contract-wall-rust codex cluster** (spec:820/2081/2225-class legs) — deterministically red at pre-campaign base `4c2297667` (2 runs, 12p/3f) AND at tip `aa9f4b7b3`: PRE-EXISTING. Owned by TERM-22's never-landed PW-RUST codex lifecycle cycles (note recorded on TERM-22). Knowingly excluded from wave-1 fitness. +2. **layout-sync-authoritative chromium leg** — was deterministic red at R2 tip; root-caused as test-side race vs client last-write-wins syncs; FIXED `5dece6822`, green ×3. +3. **helpers perf smoke ZodError (git.branch)** — detached-HEAD posture artifact; byte-identical at base; equally fails at base under same posture. Not campaign-attributable. +4. **Discovery hand-offs** (queued TODOs): `TODO-FLAKE-RULER` (RULER leg red-in-R1/green-in-R2), `TODO-CLOUD-RUST-POP` (cloud 2-worker rust-leg red population vs local green), plus prior `SIDEBAR-REGISTRY-CASE-C-BASE-RED`, `NET-FLAKY-01`, GO-wave carved-out discoveries. + +## PR split +- **PR #1 (wave 1):** anchor `36b7e09b4` — 18 items + wrap-review r1–r5 fixes. Gated by R1 (same head) + R2 (strict superset head; wrap delta is additive and was itself separately gated). +- **PR #2 (wrap batch):** `36b7e09b4..5dece6822` — 4 item merges + B005 evidence + r6 doc + gate fix; gated by R2 + per-item B005 verification + review r6 (0 majors). + +Deferred by design (later sessions): REV-01→REVIEW-01→DEFER-01 chain, GATE-01/HARNESS-10 defers, TERM-22 (with arbitration note), SESSION-09 (worker died mid-item; task-1 commit preserved on branch), CT-01, no-rust-leg tail. + +## Post-wrap main-sync (2026-08-11) +- `df1/integration` was merged with current `origin/main` (+~110 commits: rust-port mainline #633+sweep, opencode auto-titles #637, remote status rings #636, mobile context menu #635, pty-captured-leak #634) as merge commit `b87c79c02`; NOT a rebase (embedded fork-lineage sync merges make --rebase-merges degenerate; branch already pushed). +- Full conflict/resolution/gate record: docs/plans/df1-evidence/MAIN-SYNC-MERGE.md. Post-merge gate quiet-window: cargo workspace 116/116 targets / 3157 passed; npm test PASS; typecheck PASS; focused PW legs PASS; a11y deny-gate clean. +- Two main-drift dispositions fixed in-branch (`3b7112842` + `9b2ee0709`): TooltipContent pointer-events-none (fixes a main-owned title-sync spec + real right-click interception on pure origin/main) and semantic handles (`data-context="pane-header"`, `data-remote-status-ring`) so the campaign's deny-gate lands green with main's specs. +- PR split note: the wave-1 anchor `36b7e09b4` is now PRE-sync history; proposal A/B shapes change accordingly — deciding the split against the sync'd single branch is an open follow-up when the user resumes. diff --git a/docs/plans/df1-evidence/GATE-01.md b/docs/plans/df1-evidence/GATE-01.md new file mode 100644 index 000000000..77ccd017e --- /dev/null +++ b/docs/plans/df1-evidence/GATE-01.md @@ -0,0 +1,308 @@ +# GATE-01 evidence — unchanged legacy browser suite × {Node legacy, Rust} + +Item: **GATE-01 — Run the unchanged legacy browser suite against both Node and +Rust. No Rust-only skips for a user-visible feature are allowed.** +Plan/run protocol: `docs/plans/df1/GATE-01.md`. Worker branch: +`df1/gate-01-unchanged-suite-both` off `origin/df1/integration` @ `3dbba43c2`. + +## Suite definition (precise) + +The effective test selection of the `chromium` project in +`test/e2e-browser/playwright.config.ts` at the base ref: all +`test/e2e-browser/specs/*.spec.ts` **minus** the 31 `RUST_ONLY_SPECS` (each +excluded file carries a config comment documenting why it hard-fails under +the legacy Node server by design) = **69 spec files**, 280×2 = **560 tests** +(verified via `npx playwright test --config +test/e2e-browser/playwright.gate01.config.ts --list`: 280 per project). + +Run vehicle: `test/e2e-browser/playwright.gate01.config.ts` (projects +`gate01-legacy` / `gate01-rust`; ONLY the `e2eServerKind` worker option +differs; snapshots pinned to the committed `-chromium-` baselines on both +legs). Spec files are UNCHANGED except additive conditional `test.fail` +pins listed in the Annotations section below. + +Machine-readable artifact: `test/e2e-browser/gate01-baseline.json` +(per-spec × per-leg verdicts, counts, failure details, attributions; schema +documented in the collator header, `test/e2e-browser/helpers/gate01-collate.ts`). + +## Headline finding F1 — RecoveryOfferPanel interference (rust leg, designed behavior, no owner) + +**Signature:** on `gate01-rust` only, tests after the first on a worker-shared +server intermittently fail with either `.xterm` visibility timeouts +(`TerminalHelper.waitForTerminal`, 15 s) or Playwright click retries ending in +"`