Merge pull request #64 from jmfederico/fix/issue-62-authstorage

fix: migrate auth/model plumbing to ModelRuntime (fixes #62)
This commit is contained in:
Federico Jaramillo Martinez
2026-07-18 18:20:03 +02:00
committed by GitHub
43 changed files with 2183 additions and 608 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Restore session-daemon startup and authentication on supported Pi `>=0.80.8 <0.81` releases by migrating model and credential handling to `ModelRuntime`. Provider discovery now reloads model configuration and reports only complete usable credentials. Login options follow each provider's executable API-key and OAuth capabilities: multi-step API-key setup is supported, legacy one-secret clients fail safely before storing malformed credentials, and OAuth prompts retain their input, selection, and device-code semantics. A committed login remains successful through late cancellation or notification failures. Failed realtime delivery now closes only the affected socket so its browser can reconnect while healthy peers keep receiving events. PI WEB now requires Node.js `>=22.19.0`.
+2 -2
View File
@@ -34,9 +34,9 @@ Your browser is the control surface. The work stays where it can keep running.
Requirements:
- Node.js 22 or newer
- Node.js 22.19.0 or newer
- npm
- Pi Coding Agent configured for your user
- Pi Coding Agent `>=0.80.8 <0.81`, configured for your user
- git and the development tools your agents need
Install and start PI WEB as per-user services:
+2 -2
View File
@@ -157,7 +157,7 @@
<article id="doctor-fails" class="faq-item">
<h2>What does <code>pi-web doctor</code> check?</h2>
<p>
It keeps two kinds of checks separate. General login-shell readiness covers Node 22+, npm, Pi, and optional
It keeps two kinds of checks separate. General login-shell readiness covers Node 22.19.0 or newer, npm, Pi, and optional
ripgrep. Native-service diagnostics validate only the exact prerequisites of the selected service plan in
the real systemd user-manager or launchd <code>gui/&lt;uid&gt;</code> context. Development installs follow their
installed checkout plan; production checks are clearly labelled prospective when the installed executable
@@ -181,7 +181,7 @@
<ul>
<li>Prefer version-manager shims when available; for <code>mise</code>, use shims or enable its shim setup rather than relying only on shell activation.</li>
<li>Move any required version-manager initialization to your login shell file.</li>
<li>Make sure <code>node --version</code> is at least <code>v22</code> from <code>bash -lc</code>, <code>zsh -lc</code>, or your detected shell.</li>
<li>Make sure <code>node --version</code> is at least <code>v22.19.0</code> from <code>bash -lc</code>, <code>zsh -lc</code>, or your detected shell.</li>
<li>Run <code>pi-web doctor</code> again after changing shell files.</li>
</ul>
</article>
+2 -2
View File
@@ -38,7 +38,7 @@
"downloadUrl": "https://www.npmjs.com/package/@jmfederico/pi-web",
"codeRepository": "https://github.com/jmfederico/pi-web",
"description": "PI WEB is a web UI for Pi Coding Agent that keeps persistent agent sessions running in real workspaces on your machine or server.",
"softwareRequirements": "Node.js 22 or newer and Pi Coding Agent",
"softwareRequirements": "Node.js 22.19.0 or newer and Pi Coding Agent >=0.80.8 <0.81",
"license": "https://github.com/jmfederico/pi-web/blob/main/LICENSE"
}
</script>
@@ -138,7 +138,7 @@
# laptop, phone, tablet — same live sessions
<span class="prompt">$</span> pi-web doctor
✓ caller login shell can find node >= 22
✓ caller login shell can find node >= 22.19.0
✓ native-service plan requirements pass in manager context
✓ ready for persistent agent work</code></pre>
</aside>
+2 -2
View File
@@ -106,8 +106,8 @@
<section id="requirements">
<h2>Requirements</h2>
<ul>
<li><strong>Node.js 22 or newer</strong> and npm.</li>
<li><strong>Pi Coding Agent</strong> installed/configured so the <code>pi</code> command works for your user.</li>
<li><strong>Node.js 22.19.0 or newer</strong> and npm.</li>
<li><strong>Pi Coding Agent <code>&gt;=0.80.8 &lt;0.81</code></strong> installed/configured so the <code>pi</code> command works for your user.</li>
<li>A shell login environment that exposes Node, npm, Pi, git, and any tools your agents need.</li>
<li>For the automatic installer: a supported per-user service manager.</li>
</ul>
+215 -197
View File
@@ -42,9 +42,9 @@
},
"devDependencies": {
"@changesets/cli": "^2.31.0",
"@earendil-works/pi-agent-core": "^0.80.6",
"@earendil-works/pi-ai": "^0.80.6",
"@earendil-works/pi-coding-agent": "^0.80.6",
"@earendil-works/pi-agent-core": "^0.80.8",
"@earendil-works/pi-ai": "^0.80.8",
"@earendil-works/pi-coding-agent": "^0.80.8",
"@eslint/js": "^10.0.1",
"@types/node": "^24.13.3",
"@types/ws": "^8.18.1",
@@ -58,12 +58,12 @@
"vitest": "^4.1.10"
},
"engines": {
"node": ">=22"
"node": ">=22.19.0"
},
"peerDependencies": {
"@earendil-works/pi-agent-core": ">=0.80.0 <1",
"@earendil-works/pi-ai": ">=0.80.0 <1",
"@earendil-works/pi-coding-agent": ">=0.80.0 <1"
"@earendil-works/pi-agent-core": ">=0.80.8 <0.81",
"@earendil-works/pi-ai": ">=0.80.8 <0.81",
"@earendil-works/pi-coding-agent": ">=0.80.8 <0.81"
}
},
"node_modules/@anthropic-ai/sdk": {
@@ -167,18 +167,18 @@
}
},
"node_modules/@aws-sdk/core": {
"version": "3.975.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.1.tgz",
"integrity": "sha512-8qh/6EYb7hl/ZwVfQufhbMEZs1gQIc7GbdrIf4eprQJ7cv042+74nE6l3YDfyWNzb9iPXb8fRyYSHkNIk5eE6Q==",
"version": "3.975.3",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.3.tgz",
"integrity": "sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.0",
"@aws-sdk/xml-builder": "^3.972.34",
"@aws-sdk/types": "^3.974.2",
"@aws-sdk/xml-builder": "^3.972.36",
"@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.29.2",
"@smithy/signature-v4": "^5.6.3",
"@smithy/types": "^4.16.0",
"@smithy/core": "^3.29.4",
"@smithy/signature-v4": "^5.6.5",
"@smithy/types": "^4.16.1",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
},
@@ -187,16 +187,16 @@
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.57",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.57.tgz",
"integrity": "sha512-1RfJaF7SW1TOnvNGU7kaYjwUf5H3sfm+synGH1bHhRlqcnxCt3szebH3dmKEyY4tuGcbQ6ffzUT89cRitBV8OQ==",
"version": "3.972.59",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.59.tgz",
"integrity": "sha512-Ny5e4Mfh3QPmiAc0AiUe+cbTXDlxkU3Rc+EpWOfyWeWEy6yp7Fa1KmfNeCc+1a8by9zQ9gtohmiQUkMPScF3ng==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.1",
"@aws-sdk/types": "^3.974.0",
"@smithy/core": "^3.29.2",
"@smithy/types": "^4.16.0",
"@aws-sdk/core": "^3.975.3",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -204,18 +204,18 @@
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.59",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.59.tgz",
"integrity": "sha512-sRCkpTiFnCdQvuaRVjQ6SVoHu6i7RUpurVo1c4F81HWhPvUJ7Wdp5MNtSdX1O29CNXc8em3O5m52hCjVtAD9SA==",
"version": "3.972.61",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.61.tgz",
"integrity": "sha512-8jAjgStl5Ytq4+HF3X/9f+EmRinaRbGRRtQGktlPfBRVx73H+R1y48vIeXerQtYGFaUqkEp3fT6jP854rVO2yQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.1",
"@aws-sdk/types": "^3.974.0",
"@smithy/core": "^3.29.2",
"@smithy/fetch-http-handler": "^5.6.4",
"@smithy/node-http-handler": "^4.9.4",
"@smithy/types": "^4.16.0",
"@aws-sdk/core": "^3.975.3",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/fetch-http-handler": "^5.6.6",
"@smithy/node-http-handler": "^4.9.6",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -223,14 +223,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": {
"version": "4.9.4",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.4.tgz",
"integrity": "sha512-BNTop/fSOptmoVk8g+efwHCofFh37g70OWGAFES1TeAAJja1K5aAI8rTE26ETSc5k8IQuWY2kAIoPla01NgYrA==",
"version": "4.9.7",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.7.tgz",
"integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.29.2",
"@smithy/types": "^4.16.0",
"@smithy/core": "^3.29.5",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -238,24 +238,24 @@
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.973.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.1.tgz",
"integrity": "sha512-6d8H6ZAh3ZPKZ6fe1nG2OWeZEZPtt9ravoD1dezPdPtsSkJRoxGAnFSHwKT3E/Te6fHE30zRzjV6TD12rvF6yQ==",
"version": "3.973.4",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.4.tgz",
"integrity": "sha512-e6ZvVsj90aRALf1kHP+J4iqC1496ZpVgqI/+u0LJ5HL7q7ATauGy4gdDvRCP13L1pN/fMiZLah162PGIYkbUVQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.1",
"@aws-sdk/credential-provider-env": "^3.972.57",
"@aws-sdk/credential-provider-http": "^3.972.59",
"@aws-sdk/credential-provider-login": "^3.972.63",
"@aws-sdk/credential-provider-process": "^3.972.57",
"@aws-sdk/credential-provider-sso": "^3.973.1",
"@aws-sdk/credential-provider-web-identity": "^3.972.63",
"@aws-sdk/nested-clients": "^3.997.31",
"@aws-sdk/types": "^3.974.0",
"@smithy/core": "^3.29.2",
"@smithy/credential-provider-imds": "^4.4.7",
"@smithy/types": "^4.16.0",
"@aws-sdk/core": "^3.975.3",
"@aws-sdk/credential-provider-env": "^3.972.59",
"@aws-sdk/credential-provider-http": "^3.972.61",
"@aws-sdk/credential-provider-login": "^3.972.66",
"@aws-sdk/credential-provider-process": "^3.972.59",
"@aws-sdk/credential-provider-sso": "^3.973.3",
"@aws-sdk/credential-provider-web-identity": "^3.972.65",
"@aws-sdk/nested-clients": "^3.997.33",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/credential-provider-imds": "^4.4.9",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -263,17 +263,17 @@
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.63",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.63.tgz",
"integrity": "sha512-GREWRrMj0XnNKMaVa/Mauoaui26qBEHu71WWqXbwZOu/jFQOnPZjTf7u0KtGKC8VGa6VUs9kDWGgocrKNLS9vw==",
"version": "3.972.66",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.66.tgz",
"integrity": "sha512-g2fsqm87r/nKthLZ0VkkDBElkGg0PvSa8d97HQ6EilMbJTZ6hxa8FxkSZyJfgPfFdZn0TTmkOffQmTSUcAHIng==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.1",
"@aws-sdk/nested-clients": "^3.997.31",
"@aws-sdk/types": "^3.974.0",
"@smithy/core": "^3.29.2",
"@smithy/types": "^4.16.0",
"@aws-sdk/core": "^3.975.3",
"@aws-sdk/nested-clients": "^3.997.33",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -281,22 +281,22 @@
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.66",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.66.tgz",
"integrity": "sha512-f+qjRXZpz7sgzbc4QB+6nLKfyKFgRRXzWdXbsKPv/VhVRyHsDyq4yBWC/B75BAJpFIcUeI2XR/3gdWJ677zB4A==",
"version": "3.972.70",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.70.tgz",
"integrity": "sha512-3xzvkGdykBunxqh8WudmUpSyLWvIhfI6aBQo1b5rb3mDO5mNLadK+0hiI0qBQBMVynJbfLO+Ajy9dztMwy9O8w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.57",
"@aws-sdk/credential-provider-http": "^3.972.59",
"@aws-sdk/credential-provider-ini": "^3.973.1",
"@aws-sdk/credential-provider-process": "^3.972.57",
"@aws-sdk/credential-provider-sso": "^3.973.1",
"@aws-sdk/credential-provider-web-identity": "^3.972.63",
"@aws-sdk/types": "^3.974.0",
"@smithy/core": "^3.29.2",
"@smithy/credential-provider-imds": "^4.4.7",
"@smithy/types": "^4.16.0",
"@aws-sdk/credential-provider-env": "^3.972.59",
"@aws-sdk/credential-provider-http": "^3.972.61",
"@aws-sdk/credential-provider-ini": "^3.973.4",
"@aws-sdk/credential-provider-process": "^3.972.59",
"@aws-sdk/credential-provider-sso": "^3.973.3",
"@aws-sdk/credential-provider-web-identity": "^3.972.65",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/credential-provider-imds": "^4.4.9",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -304,16 +304,16 @@
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.57",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.57.tgz",
"integrity": "sha512-TiVQhuU0pbhIZAUZacbPHMyzrIdiH+lnx+PMY/Pu/b93dJrq3wdZwzUJ0TPpvNxaqbHsxJvQZW3/h/beLiKq7Q==",
"version": "3.972.59",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.59.tgz",
"integrity": "sha512-DlZF2/MhLlatDdlrIy3CUCpfdbLrKx+3SMjVo+WyHnPpwzkc/M3vwAHw4OVJf7DMvO+4vfRqSCMc/E9I1auN0g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.1",
"@aws-sdk/types": "^3.974.0",
"@smithy/core": "^3.29.2",
"@smithy/types": "^4.16.0",
"@aws-sdk/core": "^3.975.3",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -321,18 +321,18 @@
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.973.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.1.tgz",
"integrity": "sha512-3foTZUJ4821Ij60X7K3NJroygiZLnbBmarN+T//O2cjkISan90zElN3NBmgSlDrTQ7Gs6z/yO8V7h60QNcDZHQ==",
"version": "3.973.3",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.3.tgz",
"integrity": "sha512-hmdDHoy2G5Es2e8IgelNMYUuSQI6uCIAKZMJ2u2PdKDhxvbk1uWD/g4+R7R5c/tJfKEB1+KjjWiaoCr/S+ZTiQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.1",
"@aws-sdk/nested-clients": "^3.997.31",
"@aws-sdk/token-providers": "3.1083.0",
"@aws-sdk/types": "^3.974.0",
"@smithy/core": "^3.29.2",
"@smithy/types": "^4.16.0",
"@aws-sdk/core": "^3.975.3",
"@aws-sdk/nested-clients": "^3.997.33",
"@aws-sdk/token-providers": "3.1088.0",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -340,17 +340,17 @@
}
},
"node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": {
"version": "3.1083.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1083.0.tgz",
"integrity": "sha512-s0woKnxuHrExLc5L2ArIH5BMkbonHPtt+5hSBM8oknp9M6QTuUmmAmJ2E0EdzCGONrO+8+ADPqvv6UX0nNcc7A==",
"version": "3.1088.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1088.0.tgz",
"integrity": "sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.1",
"@aws-sdk/nested-clients": "^3.997.31",
"@aws-sdk/types": "^3.974.0",
"@smithy/core": "^3.29.2",
"@smithy/types": "^4.16.0",
"@aws-sdk/core": "^3.975.3",
"@aws-sdk/nested-clients": "^3.997.33",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -358,17 +358,17 @@
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.63",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.63.tgz",
"integrity": "sha512-8qZLFhM69eKcS37m459ctPR05Qimycm/74OPVioe6wNZabMT54GYhwBju0+J656RkMasNSawWQu+c8CmBe3TUQ==",
"version": "3.972.65",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.65.tgz",
"integrity": "sha512-gHQb/Kt0chjk/JQDa/GJDqmAvEuVn8n7z10wK2h0LFM9TUDRkohgOO4aEF+s2sBLM0br7Cl5W6P7phgjrrJvLQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.1",
"@aws-sdk/nested-clients": "^3.997.31",
"@aws-sdk/types": "^3.974.0",
"@smithy/core": "^3.29.2",
"@smithy/types": "^4.16.0",
"@aws-sdk/core": "^3.975.3",
"@aws-sdk/nested-clients": "^3.997.33",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -376,15 +376,15 @@
}
},
"node_modules/@aws-sdk/eventstream-handler-node": {
"version": "3.972.26",
"resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.26.tgz",
"integrity": "sha512-RE1fu7Nn05vG0EUJM+8Sde2GFecC658WGaC/asPzLF6K4x3H5ZaDBcQtHRE67Gdgb1VZpyUUliYejHFK1qt0Uw==",
"version": "3.972.29",
"resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.29.tgz",
"integrity": "sha512-t3tKQRTVXsI2QNPE3CaNjHl0wRO9Xi3acZkAyti2RQsiFmZ9Gi0kArX2ighlRJ1BtDVuul413gThAgzyTfgmWA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.0",
"@smithy/core": "^3.29.2",
"@smithy/types": "^4.16.0",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -392,15 +392,15 @@
}
},
"node_modules/@aws-sdk/middleware-eventstream": {
"version": "3.972.22",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.22.tgz",
"integrity": "sha512-jtkgmhevnpzC1WeS+Y/sgymYbaQ6qg7pVOUl5cUT/8MiLptqrtnXQlNV80m+j2WIx5MIL7kVHIZNxxcK2tfUEQ==",
"version": "3.972.24",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.24.tgz",
"integrity": "sha512-oykin4mDWxNOuYQ7SF1cHzgYeuFEkF4cdRwgvjFFbIklkx09qIFBiOgsORafG9sXZFO3TayMmQuAQYgADXhI8w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.0",
"@smithy/core": "^3.29.2",
"@smithy/types": "^4.16.0",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -408,18 +408,18 @@
}
},
"node_modules/@aws-sdk/middleware-websocket": {
"version": "3.972.39",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.39.tgz",
"integrity": "sha512-CS1spxRSezmTmI3PD+3Xrnp6KryTSEz0EefA8u6uGd0s2I0uXseWHALDI/03Wi0IUczXNWo2QrZEaHDuJNby/Q==",
"version": "3.972.41",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.41.tgz",
"integrity": "sha512-LSbGvvYmjc4Br9BPYI2dTLnIclmrSiQbahkP4D6nRGVEv4qsCZ8csVuKBPVEEFCVD+EEngGh8ROls6XpumtwMg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.1",
"@aws-sdk/types": "^3.974.0",
"@smithy/core": "^3.29.2",
"@smithy/fetch-http-handler": "^5.6.4",
"@smithy/signature-v4": "^5.6.3",
"@smithy/types": "^4.16.0",
"@aws-sdk/core": "^3.975.3",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/fetch-http-handler": "^5.6.6",
"@smithy/signature-v4": "^5.6.5",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -427,19 +427,19 @@
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.31",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.31.tgz",
"integrity": "sha512-BDHTpwcsZHEBNEJzOg/B1BkFYJxAXY50dau/NyVWs3d51F0WgIUGSWZot/Os+N3KpDhXeaXnz37mWffAvduREw==",
"version": "3.997.33",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.33.tgz",
"integrity": "sha512-dVZOroI/r3/ENvqNGgjMPul+jjlz9GddfVusgTXlVjfZj5isibOxecLkGQbRPp8XOuX+RAfjXLFgPkD1JS5xrw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.1",
"@aws-sdk/signature-v4-multi-region": "^3.996.39",
"@aws-sdk/types": "^3.974.0",
"@smithy/core": "^3.29.2",
"@smithy/fetch-http-handler": "^5.6.4",
"@smithy/node-http-handler": "^4.9.4",
"@smithy/types": "^4.16.0",
"@aws-sdk/core": "^3.975.3",
"@aws-sdk/signature-v4-multi-region": "^3.996.41",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/fetch-http-handler": "^5.6.6",
"@smithy/node-http-handler": "^4.9.6",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -447,14 +447,14 @@
}
},
"node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": {
"version": "4.9.4",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.4.tgz",
"integrity": "sha512-BNTop/fSOptmoVk8g+efwHCofFh37g70OWGAFES1TeAAJja1K5aAI8rTE26ETSc5k8IQuWY2kAIoPla01NgYrA==",
"version": "4.9.7",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.7.tgz",
"integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.29.2",
"@smithy/types": "^4.16.0",
"@smithy/core": "^3.29.5",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -462,15 +462,15 @@
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.39",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.39.tgz",
"integrity": "sha512-8+srXqYIF8KYMLC4FxMLEM5Ek7kUNibJu1R4m8/fUhhNYIZZz26oGtKkCr8I/HiG2fFQxBvaGgQZT4/mqRCSnA==",
"version": "3.996.41",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.41.tgz",
"integrity": "sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.0",
"@smithy/signature-v4": "^5.6.3",
"@smithy/types": "^4.16.0",
"@aws-sdk/types": "^3.974.2",
"@smithy/signature-v4": "^5.6.5",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -496,13 +496,13 @@
}
},
"node_modules/@aws-sdk/types": {
"version": "3.974.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz",
"integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==",
"version": "3.974.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz",
"integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.16.0",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -523,13 +523,13 @@
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.34",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.34.tgz",
"integrity": "sha512-wHhWL1y7sN3enBA8POrPpQM5jCcmu2ozyhbRei4c8OjVcEaEs6yLucLa/pla457ggS/ysuy7bosagz3HaJkZXA==",
"version": "3.972.36",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.36.tgz",
"integrity": "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.16.0",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -984,13 +984,13 @@
}
},
"node_modules/@earendil-works/pi-agent-core": {
"version": "0.80.6",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.6.tgz",
"integrity": "sha512-Lvn89ko42h5ETUb6Z0Ku6ldskEqXaTdQBYvSa0+7bdG9V6rUEpXptv5e0OVZ1HDcvi8s6/2lGCQWsxKX+DFHNw==",
"version": "0.80.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz",
"integrity": "sha512-nwnOR3SuLYGRFfyQm8ri4Nj5VGVAvAM9GuqQd3u7BUQj0d6hmD2F8w7OHAAjThE3CuySIdM+v8E22QJG6/RfCg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^0.80.6",
"@earendil-works/pi-ai": "^0.80.10",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
@@ -1007,9 +1007,9 @@
"license": "MIT"
},
"node_modules/@earendil-works/pi-ai": {
"version": "0.80.6",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.6.tgz",
"integrity": "sha512-7xfLk8sANBp+bpPEbjoOZTbPxsa+++b1JXAoSJsNa3vbs9AHHEclmvg54XLQcxH+fuwaeti/g2jeIfJ+mVYLpA==",
"version": "0.80.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz",
"integrity": "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1040,15 +1040,16 @@
"license": "MIT"
},
"node_modules/@earendil-works/pi-coding-agent": {
"version": "0.80.6",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.6.tgz",
"integrity": "sha512-vcfD6tOk402isLl3Cm/qbn2O10TvgroMp1+/fEGM24ZdvETFCdOYv5VZ7m59EI5fPsjfSJh+CpQ5bhBrhfOg7g==",
"version": "0.80.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.10.tgz",
"integrity": "sha512-aL4apbupCHiVLSXASXvRzH4Q2vmtfrDa+0s909CJuVu/GgGylbDzr7oyF1mPmip5E+VxYYxKWmph4hV04wUcQg==",
"dev": true,
"hasShrinkwrap": true,
"license": "MIT",
"dependencies": {
"@earendil-works/pi-agent-core": "^0.80.6",
"@earendil-works/pi-ai": "^0.80.6",
"@earendil-works/pi-tui": "^0.80.6",
"@earendil-works/pi-agent-core": "^0.80.10",
"@earendil-works/pi-ai": "^0.80.10",
"@earendil-works/pi-tui": "^0.80.10",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"cross-spawn": "7.0.6",
@@ -1538,12 +1539,12 @@
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": {
"version": "0.80.6",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.6.tgz",
"version": "0.80.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz",
"dev": true,
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^0.80.6",
"@earendil-works/pi-ai": "^0.80.10",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
@@ -1553,8 +1554,8 @@
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": {
"version": "0.80.6",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.6.tgz",
"version": "0.80.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1571,15 +1572,15 @@
"typebox": "1.1.38"
},
"bin": {
"pi-ai": "./dist/cli.js"
"pi-ai": "dist/cli.js"
},
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": {
"version": "0.80.6",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.6.tgz",
"version": "0.80.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.10.tgz",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2076,6 +2077,16 @@
"node": ">=14.0.0"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -2894,6 +2905,13 @@
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
@@ -5327,13 +5345,13 @@
"license": "MIT"
},
"node_modules/@smithy/core": {
"version": "3.29.2",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.2.tgz",
"integrity": "sha512-DXUk6yU0C1Q1tYvJh1VCtl8QOBcSoZpKwjTPkxT6A4MUQYHvgeKGByL8mrEdxnvhdf9nq5GyzmRb5n/vPgu3Lw==",
"version": "3.29.5",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.5.tgz",
"integrity": "sha512-i0dk2t5B+CwV/dcJdUHILYkOQF5lof8f44dFCfDWToGCxjT9YQ+CgHqTAvJxzc3+zqQwm2QtVoJ5IqiNar/CnQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.16.0",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -5341,14 +5359,14 @@
}
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.4.7",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.7.tgz",
"integrity": "sha512-UEMLOoA0Fl4uYBxh6l0uN0H6EJe/A89OGeDNTteQeXpJ20BcpfIr4wlCY9pel1jEAUHAxaYwuqrYlrKdXE1GKQ==",
"version": "4.4.10",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.10.tgz",
"integrity": "sha512-MJenAe4OKRZUo1LdYYFDCsSHxaHvInIU/z52GsheO9vl1/VSySVCr0zkyKD6TFiGkSUaWGxvKZ/70OvgUZR5HQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.29.2",
"@smithy/types": "^4.16.0",
"@smithy/core": "^3.29.5",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -5356,14 +5374,14 @@
}
},
"node_modules/@smithy/fetch-http-handler": {
"version": "5.6.4",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.4.tgz",
"integrity": "sha512-psnst7NZWdAEvJvyW8YZEE7xNVMyLrQFfHtyrVFrxNyy+dKWkQ+rqC6oI5ZhxThpUy9RSfEshgm34zqbOxzsRw==",
"version": "5.6.7",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.7.tgz",
"integrity": "sha512-3zpg8yqqyXzoK2TsRDdkqVOj2RDBFfLXwCczOZ5c7TWB4eiaebfSCsbMjDPYB3PJ9ihV62QaeadZ+wLadZtNGA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.29.2",
"@smithy/types": "^4.16.0",
"@smithy/core": "^3.29.5",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -5399,14 +5417,14 @@
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.3.tgz",
"integrity": "sha512-8qVKKzqh7naF27ePmx0SkUfnGP/wBI9dyaeAmhHvopnbIlItUAmB/e6PkPCU3rRb2v9BY8D4EZXSoydSibatvw==",
"version": "5.6.6",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.6.tgz",
"integrity": "sha512-efP6DN3UTFrzIsGO42/xcabv8jU7+9nwEdphFUH7yL0k010ERyAWaO41KFQIDLcFZLZ8xzIQr4wplFxNzslSGQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.29.2",
"@smithy/types": "^4.16.0",
"@smithy/core": "^3.29.5",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -5414,9 +5432,9 @@
}
},
"node_modules/@smithy/types": {
"version": "4.16.0",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.0.tgz",
"integrity": "sha512-aVUabzlBBmY0PfvVgLKQSOGFIL5/7R54JE3uD9a5Ay/jSED61SkuAcCYENNXJzYUvJ1NPrWO0P+rAXHCkbBUKw==",
"version": "4.16.1",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz",
"integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
+7 -7
View File
@@ -82,9 +82,9 @@
},
"devDependencies": {
"@changesets/cli": "^2.31.0",
"@earendil-works/pi-agent-core": "^0.80.6",
"@earendil-works/pi-ai": "^0.80.6",
"@earendil-works/pi-coding-agent": "^0.80.6",
"@earendil-works/pi-agent-core": "^0.80.8",
"@earendil-works/pi-ai": "^0.80.8",
"@earendil-works/pi-coding-agent": "^0.80.8",
"@eslint/js": "^10.0.1",
"@types/node": "^24.13.3",
"@types/ws": "^8.18.1",
@@ -101,7 +101,7 @@
"access": "public"
},
"engines": {
"node": ">=22"
"node": ">=22.19.0"
},
"repository": {
"type": "git",
@@ -113,9 +113,9 @@
"homepage": "https://pi-web.dev/",
"packageManager": "[email protected]",
"peerDependencies": {
"@earendil-works/pi-agent-core": ">=0.80.0 <1",
"@earendil-works/pi-ai": ">=0.80.0 <1",
"@earendil-works/pi-coding-agent": ">=0.80.0 <1"
"@earendil-works/pi-agent-core": ">=0.80.8 <0.81",
"@earendil-works/pi-ai": ">=0.80.8 <0.81",
"@earendil-works/pi-coding-agent": ">=0.80.8 <0.81"
},
"keywords": [
"pi-package",
+13
View File
@@ -8,6 +8,7 @@ import {
doctorExitCode,
isCliEntrypoint,
launchdRuntimeDetails,
nodeVersionCheck,
regularFileExists,
serviceBackendForPlatform,
} from "./cli.js";
@@ -58,6 +59,18 @@ describe("commandWithVersionCheck", () => {
});
});
describe("nodeVersionCheck", () => {
it("checks the complete supported Node version with the resolved executable", () => {
process.env["SHELL"] = "/bin/bash";
const command = nodeVersionCheck();
expect(command).toContain("22.19.0");
expect(command).toContain("process.versions.node");
expect(command).toContain("\"$pi_web_probe_executable\"");
});
});
describe("agentCommandForChecks", () => {
it("reads the configured agent command for doctor checks", () => {
const dir = mkdtempSync(join(tmpdir(), "pi-web-cli-test-"));
+10 -6
View File
@@ -15,6 +15,7 @@ import {
type NativeServiceInstallFailure,
} from "./nativeServices/serviceInstall.js";
import {
minimumSupportedNodeVersion,
nativeServiceManagerRefs,
productionNativeServiceIds,
type NativeServiceBackend,
@@ -772,11 +773,14 @@ export function commandWithVersionCheck(command: string): string {
return `${found} && (${commandWord} --version 2>&1 || true)`;
}
function nodeVersionCheck(): string {
return [
commandCheck("node"),
"node -e \"const major = Number(process.versions.node.split('.')[0]); console.log(process.version); process.exit(major >= 22 ? 0 : 1);\"",
].join(" && ");
export function nodeVersionCheck(): string {
return nativeServicePrerequisiteShellCheck(detectServiceShell().name, {
id: "caller.node",
kind: "node-version",
command: "node",
minimumVersion: minimumSupportedNodeVersion,
description: `node >= ${minimumSupportedNodeVersion}`,
});
}
export function agentCommandForChecks(env: NodeJS.ProcessEnv = process.env): string {
@@ -787,7 +791,7 @@ function generalDoctorChecks(): Check[] {
const shell = serviceShellLabel();
const agentCommand = agentCommandForChecks();
return [
[`Caller login ${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())],
[`Caller login ${shell} can find node >= ${minimumSupportedNodeVersion}`, serviceShellCommand(nodeVersionCheck())],
[`Caller login ${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
[`Caller login ${shell} can find ${agentCommand}`, serviceShellCommand(commandWithVersionCheck(agentCommand))],
];
+1
View File
@@ -241,6 +241,7 @@ export const sessionsApi = {
return request(`${machinePrefix(options?.machineId)}/auth/providers${query === "" ? "" : `?${query}`}`, parseAuthProvidersResponse);
},
saveApiKey: (providerId: string, key: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/api-key`, parseAccepted, { method: "POST", body: JSON.stringify({ providerId, key }) }),
startInteractiveApiKeyLogin: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/api-key/interactive`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ providerId }) }),
logoutProvider: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/logout`, parseAccepted, { method: "POST", body: JSON.stringify({ providerId }) }),
startOAuthLogin: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ providerId }) }),
oauthFlow: (flowId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth/${encodeURIComponent(flowId)}`, parseOAuthFlowState),
@@ -88,6 +88,7 @@ describe("federated route contract", () => {
ignoreParseFailure(sessionsApi.detachParent(session, machineId)),
ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })),
ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)),
ignoreParseFailure(sessionsApi.startInteractiveApiKeyLogin("amazon-bedrock", machineId)),
ignoreParseFailure(sessionsApi.logoutProvider("openai", machineId)),
ignoreParseFailure(sessionsApi.startOAuthLogin("openai", machineId)),
ignoreParseFailure(sessionsApi.oauthFlow("flow 1", machineId)),
+52 -1
View File
@@ -1,8 +1,59 @@
import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSessionStreamSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
import { parseAuthProvidersResponse, parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSessionStreamSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => {
it("preserves additive interactive API-key flow hints and defaults legacy options", () => {
const base = { id: "openai", name: "OpenAI", authType: "api_key", status: { configured: false } };
expect(parseAuthProvidersResponse({ providers: [{ ...base, loginFlow: "interactive" }, base] }).providers).toEqual([
{ ...base, loginFlow: "interactive" },
base,
]);
});
it("preserves additive OAuth interaction semantics", () => {
expect(parseOAuthFlowState({
flowId: "flow-1",
providerId: "provider",
providerName: "Provider",
status: "running",
auth: {
url: "https://example.test/device",
instructions: "Enter code",
deviceCode: { userCode: "ABCD", intervalSeconds: 5, expiresInSeconds: 900 },
},
prompt: { requestId: "prompt-1", message: "Secret", kind: "prompt", promptType: "secret", allowEmpty: false, placeholder: "token" },
select: { requestId: "select-1", message: "Choose", options: [{ value: "work", label: "Work", description: "Company account" }] },
progress: ["Read the guide"],
info: [{ message: "Read the guide", links: [{ url: "https://example.test/docs", label: "Guide" }] }],
})).toMatchObject({
auth: { deviceCode: { userCode: "ABCD", intervalSeconds: 5, expiresInSeconds: 900 } },
prompt: { kind: "prompt", promptType: "secret", allowEmpty: false },
select: { options: [{ value: "work", description: "Company account" }] },
info: [{ links: [{ url: "https://example.test/docs", label: "Guide" }] }],
});
});
it("defaults semantic prompt types from legacy OAuth wire kinds", () => {
const flow = {
flowId: "flow-1",
providerId: "provider",
providerName: "Provider",
status: "running",
progress: [],
};
expect(parseOAuthFlowState({ ...flow, prompt: { requestId: "text", message: "Value", kind: "prompt" } }).prompt).toMatchObject({
kind: "prompt",
promptType: "text",
});
expect(parseOAuthFlowState({ ...flow, prompt: { requestId: "manual", message: "Code", kind: "manual" } }).prompt).toMatchObject({
kind: "manual",
promptType: "manual_code",
});
});
it("parses PI WEB config responses", () => {
expect(parsePiWebConfigResponse({
path: "/tmp/config.json",
+55 -3
View File
@@ -369,7 +369,15 @@ function parseAuthProviderStatus(value: unknown): AuthProviderStatus {
function parseAuthProviderOption(value: unknown): AuthProviderOption {
const record = requireRecord(value);
return { id: requireString(record, "id"), name: requireString(record, "name"), authType: parseAuthType(record["authType"]), status: parseAuthProviderStatus(record["status"]) };
const loginFlow = record["loginFlow"];
if (loginFlow !== undefined && loginFlow !== "interactive") throw new Error("Invalid auth provider login flow");
return {
id: requireString(record, "id"),
name: requireString(record, "name"),
authType: parseAuthType(record["authType"]),
status: parseAuthProviderStatus(record["status"]),
...(loginFlow === undefined ? {} : { loginFlow }),
};
}
export function parseAuthProvidersResponse(value: unknown): AuthProvidersResponse {
@@ -392,6 +400,7 @@ export function parseOAuthFlowState(value: unknown): OAuthFlowState {
...optionalField("auth", optionalOAuthAuth(record["auth"])),
...optionalField("prompt", optionalOAuthPrompt(record["prompt"])),
...optionalField("select", optionalOAuthSelect(record["select"])),
...optionalField("info", optionalOAuthInfo(record["info"])),
};
return flow;
}
@@ -404,7 +413,21 @@ function parseOAuthFlowStatus(value: unknown): OAuthFlowState["status"] {
function optionalOAuthAuth(value: unknown): OAuthFlowState["auth"] | undefined {
if (value === undefined) return undefined;
const record = requireRecord(value);
return { url: requireString(record, "url"), ...optionalField("instructions", optionalString(record, "instructions")) };
return {
url: requireString(record, "url"),
...optionalField("instructions", optionalString(record, "instructions")),
...optionalField("deviceCode", optionalOAuthDeviceCode(record["deviceCode"])),
};
}
function optionalOAuthDeviceCode(value: unknown): NonNullable<OAuthFlowState["auth"]>["deviceCode"] | undefined {
if (value === undefined) return undefined;
const record = requireRecord(value);
return {
userCode: requireString(record, "userCode"),
...optionalField("intervalSeconds", optionalNumber(record, "intervalSeconds")),
...optionalField("expiresInSeconds", optionalNumber(record, "expiresInSeconds")),
};
}
function optionalOAuthPrompt(value: unknown): OAuthFlowState["prompt"] | undefined {
@@ -412,7 +435,20 @@ function optionalOAuthPrompt(value: unknown): OAuthFlowState["prompt"] | undefin
const record = requireRecord(value);
const kind = requireString(record, "kind");
if (kind !== "prompt" && kind !== "manual") throw new Error("Invalid OAuth prompt kind");
return { requestId: requireString(record, "requestId"), message: requireString(record, "message"), kind, ...optionalField("placeholder", optionalString(record, "placeholder")), ...(record["allowEmpty"] === true ? { allowEmpty: true } : {}) };
const promptType = record["promptType"] === undefined ? (kind === "manual" ? "manual_code" : "text") : parseOAuthPromptType(record["promptType"]);
return {
requestId: requireString(record, "requestId"),
message: requireString(record, "message"),
kind,
promptType,
...optionalField("placeholder", optionalString(record, "placeholder")),
...optionalField("allowEmpty", optionalBoolean(record, "allowEmpty")),
};
}
function parseOAuthPromptType(value: unknown): "text" | "secret" | "manual_code" {
if (value !== "text" && value !== "secret" && value !== "manual_code") throw new Error("Invalid OAuth prompt type");
return value;
}
function optionalOAuthSelect(value: unknown): OAuthFlowState["select"] | undefined {
@@ -421,6 +457,22 @@ function optionalOAuthSelect(value: unknown): OAuthFlowState["select"] | undefin
return { requestId: requireString(record, "requestId"), message: requireString(record, "message"), options: arrayOf(parseCommandOption)(record["options"]) };
}
function optionalOAuthInfo(value: unknown): OAuthFlowState["info"] | undefined {
if (value === undefined) return undefined;
return arrayOf((item) => {
const record = requireRecord(item);
return {
message: requireString(record, "message"),
...optionalField("links", record["links"] === undefined ? undefined : arrayOf(parseOAuthInfoLink)(record["links"])),
};
})(value);
}
function parseOAuthInfoLink(value: unknown): NonNullable<NonNullable<OAuthFlowState["info"]>[number]["links"]>[number] {
const record = requireRecord(value);
return { url: requireString(record, "url"), ...optionalField("label", optionalString(record, "label")) };
}
function optionalContextUsage(value: unknown): Pick<SessionStatus, "contextUsage"> | object {
if (value === undefined) return {};
const record = requireRecord(value);
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { oauthPromptInputType } from "./AuthDialog";
describe("oauthPromptInputType", () => {
it("renders additive secret prompts as password inputs and defaults legacy prompts to text", () => {
expect(oauthPromptInputType("secret")).toBe("password");
expect(oauthPromptInputType("text")).toBe("text");
expect(oauthPromptInputType("manual_code")).toBe("text");
expect(oauthPromptInputType(undefined)).toBe("text");
});
});
+27 -7
View File
@@ -1,7 +1,7 @@
import { LitElement, css, html } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import type { AuthDialogState } from "../appState";
import type { AuthProviderOption } from "../api";
import type { AuthProviderOption, OAuthFlowState } from "../api";
import { commandPickerStyles } from "./shared";
@customElement("auth-dialog")
@@ -42,7 +42,7 @@ export class AuthDialog extends LitElement {
private dialogTitle(state: AuthDialogState): string {
switch (state.step) {
case "method": return "Configure provider authentication";
case "providers": return state.authType === undefined ? "Select provider authentication" : state.authType === "oauth" ? "Select subscription provider" : "Select API key provider";
case "providers": return state.authType === undefined ? "Select provider authentication" : state.authType === "oauth" ? "Select subscription provider" : "Select credential provider";
case "apiKey": return `API key for ${state.provider.name}`;
case "oauth": return `Login to ${state.flow.providerName}`;
case "logout": return "Remove stored provider authentication";
@@ -54,7 +54,7 @@ export class AuthDialog extends LitElement {
case "method": return html`
<div class="options">
<button @click=${() => { this.onChooseMethod?.("oauth"); }}><span>Use a subscription</span><small>ChatGPT Plus/Pro, Claude Pro/Max, or GitHub Copilot</small></button>
<button @click=${() => { this.onChooseMethod?.("api_key"); }}><span>Use an API key</span><small>Store an API key in the active Pi-compatible profile's auth.json</small></button>
<button @click=${() => { this.onChooseMethod?.("api_key"); }}><span>Use provider credentials</span><small>Configure an API key or provider-specific credentials in the active Pi-compatible profile's auth.json</small></button>
</div>
`;
case "providers": return html`<div class="options">${state.providers.length === 0 ? html`<div class="empty">No providers available.</div>` : state.providers.map((provider) => this.renderProviderButton(provider))}</div>`;
@@ -86,22 +86,35 @@ export class AuthDialog extends LitElement {
const flow = state.flow;
const prompt = flow.prompt;
const select = flow.select;
const promptInputType = oauthPromptInputType(prompt?.promptType);
return html`
<div class="form">
${flow.auth !== undefined ? html`
<p>Open this authorization link:</p>
<p><a href=${flow.auth.url} target="_blank" rel="noreferrer">${flow.auth.url}</a></p>
${flow.auth.instructions !== undefined ? html`<p class="warning">${flow.auth.instructions}</p>` : null}
${flow.auth.deviceCode !== undefined ? html`
<p class="warning">Enter code: <code>${flow.auth.deviceCode.userCode}</code></p>
` : flow.auth.instructions !== undefined ? html`<p class="warning">${flow.auth.instructions}</p>` : null}
` : html`<p>Starting login flow…</p>`}
${flow.progress.length > 0 ? html`<ul class="progress">${flow.progress.map((line) => html`<li>${line}</li>`)}</ul>` : null}
${flow.info?.map((item) => item.links === undefined || item.links.length === 0 ? null : html`
<div class="info-links" aria-label="Related information">
${item.links.map((link) => html`<a href=${link.url} target="_blank" rel="noreferrer" title=${item.message}>${link.label ?? link.url}</a>`)}
</div>
`) ?? null}
${prompt !== undefined ? html`
<label>${prompt.message}</label>
<input .value=${state.inputValue ?? ""} placeholder=${prompt.placeholder ?? ""} @input=${(event: Event) => { if (event.target instanceof HTMLInputElement) this.onOAuthInput?.(event.target.value); }}>
<input type=${promptInputType} autocomplete=${promptInputType === "password" ? "off" : "on"} .value=${state.inputValue ?? ""} placeholder=${prompt.placeholder ?? ""} @input=${(event: Event) => { if (event.target instanceof HTMLInputElement) this.onOAuthInput?.(event.target.value); }}>
<div class="actions"><button @click=${() => { this.onOAuthCancel?.(); }}>Cancel</button><button class="primary" ?disabled=${state.responding === true} @click=${() => { this.onOAuthRespond?.(); }}>Submit</button></div>
` : null}
${select !== undefined ? html`
<p>${select.message}</p>
<div class="inline-options">${select.options.map((option) => html`<button @click=${() => { this.onOAuthRespond?.(option.value); }}>${option.label}</button>`)}</div>
<div class="inline-options">${select.options.map((option) => html`
<button @click=${() => { this.onOAuthRespond?.(option.value); }}>
<span>${option.label}</span>
${option.description === undefined ? null : html`<small>${option.description}</small>`}
</button>
`)}</div>
` : null}
${state.error !== undefined && state.error !== "" ? html`<div class="error-text">${state.error}</div>` : null}
${flow.status === "error" || flow.status === "cancelled" ? html`<div class="error-text">${flow.error ?? flow.status}</div><div class="actions"><button @click=${() => { this.cancel(); }}>Close</button></div>` : null}
@@ -157,13 +170,20 @@ export class AuthDialog extends LitElement {
.warning { color: var(--pi-warning); }
.error-text { color: var(--pi-danger); }
.progress { margin: 0; padding-left: 18px; color: var(--pi-muted); }
.info-links { display: flex; flex-wrap: wrap; gap: 8px 12px; }
.inline-options { display: grid; gap: 8px; }
.inline-options button { display: grid; gap: 2px; text-align: left; }
.inline-options small { color: var(--pi-muted); }
em { color: var(--pi-success); font-style: normal; font-size: 12px; }
`];
}
export function oauthPromptInputType(promptType: NonNullable<OAuthFlowState["prompt"]>["promptType"]): "text" | "password" {
return promptType === "secret" ? "password" : "text";
}
function authTypeLabel(authType: "oauth" | "api_key"): string {
return authType === "oauth" ? "subscription" : "API key";
return authType === "oauth" ? "subscription" : "credentials";
}
function focusKey(state: AuthDialogState | undefined): string | undefined {
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { api as defaultApi, type AuthProviderOption, type OAuthFlowState, type SessionInfo, type SessionStatus } from "../api";
import { initialAppState, type AppState } from "../appState";
import { AuthController, parseAuthSlashCommand } from "./authController";
@@ -31,6 +31,34 @@ describe("AuthController", () => {
expect(getState().authDialog).toMatchObject({ step: "apiKey", provider: { id: "anthropic", authType: "api_key" } });
});
it("starts provider-driven API-key interactions instead of opening the legacy one-secret form", async () => {
vi.stubGlobal("window", { setInterval: () => 1, clearInterval: () => undefined });
const provider: AuthProviderOption = { ...authProvider("amazon-bedrock", "api_key"), loginFlow: "interactive" };
const calls: { providerId: string; machineId: string | undefined }[] = [];
const { controller, getState } = createController(
{ authDialog: { step: "providers", mode: "login", authType: "api_key", providers: [provider] } },
{
startInteractiveApiKeyLogin: (providerId, machineId) => {
calls.push({ providerId, machineId });
return Promise.resolve(oauthFlow({ providerId, providerName: "Amazon Bedrock", select: { requestId: "request-1", message: "Choose method", options: [] } }));
},
},
);
try {
await controller.selectLoginProvider(provider.id, "api_key");
expect(calls).toEqual([{ providerId: "amazon-bedrock", machineId: "local" }]);
expect(getState().authDialog).toMatchObject({
step: "oauth",
flow: { providerId: "amazon-bedrock", select: { requestId: "request-1" } },
});
} finally {
controller.dispose();
vi.unstubAllGlobals();
}
});
it("keeps OAuth prompt input and submit state across poll refreshes for the same request", async () => {
const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
const { controller, getState } = createController(
@@ -43,6 +71,26 @@ describe("AuthController", () => {
expect(getState().authDialog).toMatchObject({ step: "oauth", inputValue: "https://callback", responding: true });
});
it("submits an allowed blank OAuth text response without client-side rejection", async () => {
const flow = oauthFlow({
prompt: { requestId: "request-1", message: "GitHub Enterprise URL/domain (blank for github.com)", kind: "prompt", promptType: "text", allowEmpty: true },
});
const respondCalls: string[] = [];
const { controller } = createController(
{ authDialog: { step: "oauth", flow, inputValue: "" } },
{
respondOAuthFlow: (_flowId, _requestId, value) => {
respondCalls.push(value);
return Promise.resolve(oauthFlow({ status: "complete" }));
},
},
);
await controller.respondOAuth();
expect(respondCalls).toEqual([""]);
});
it("resets OAuth prompt input and submit state when the request id changes", async () => {
const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
const { controller, getState } = createController(
@@ -114,6 +162,108 @@ describe("AuthController", () => {
});
});
it("does not recreate an OAuth dialog when a pending response settles during cancellation", async () => {
const prompt = { requestId: "request-1", message: "Paste callback", kind: "manual" } as const;
const flow = oauthFlow({ prompt });
const response = deferred<OAuthFlowState>();
const cancellation = deferred<OAuthFlowState>();
const { controller, getState } = createController(
{ authDialog: { step: "oauth", flow, inputValue: "https://callback" } },
{
respondOAuthFlow: () => response.promise,
cancelOAuthFlow: () => cancellation.promise,
},
);
const responsePending = controller.respondOAuth();
const cancellationPending = controller.cancelOAuth();
const dialogAfterCancel = getState().authDialog;
response.resolve(oauthFlow({ prompt, progress: ["Stale response"] }));
await responsePending;
const dialogAfterResponse = getState().authDialog;
cancellation.resolve(oauthFlow({ status: "cancelled" }));
await cancellationPending;
expect(dialogAfterCancel).toBeUndefined();
expect(dialogAfterResponse).toBeUndefined();
expect(getState().authDialog).toBeUndefined();
});
it("does not let a stale OAuth response overwrite a newer flow", async () => {
vi.stubGlobal("window", { setInterval: () => 1, clearInterval: () => undefined });
const oldPrompt = { requestId: "request-1", message: "Paste callback", kind: "manual" } as const;
const oldFlow = oauthFlow({ prompt: oldPrompt });
const newFlow = oauthFlow({ flowId: "flow-2", prompt: { requestId: "request-2", message: "Paste callback", kind: "manual" } });
const response = deferred<OAuthFlowState>();
const providers = [authProvider("anthropic", "oauth")];
const { controller, getState } = createController(
{ authDialog: { step: "oauth", flow: oldFlow, inputValue: "https://old-callback" } },
{
respondOAuthFlow: () => response.promise,
authProviders: () => Promise.resolve({ providers }),
startOAuthLogin: () => Promise.resolve(newFlow),
},
);
try {
const responsePending = controller.respondOAuth();
await controller.openLogin("anthropic");
const dialogAfterNewFlow = getState().authDialog;
response.resolve(oauthFlow({ prompt: oldPrompt, progress: ["Stale response"] }));
await responsePending;
expect(dialogAfterNewFlow).toMatchObject({ step: "oauth", flow: { flowId: "flow-2" } });
expect(getState().authDialog).toMatchObject({ step: "oauth", flow: { flowId: "flow-2" } });
} finally {
response.resolve(oldFlow);
controller.dispose();
vi.unstubAllGlobals();
}
});
it("does not let an older poll restore a running flow after a newer poll stops polling", async () => {
vi.useFakeTimers();
vi.stubGlobal("window", { setInterval: globalThis.setInterval, clearInterval: globalThis.clearInterval });
const prompt = { requestId: "request-1", message: "Paste callback", kind: "manual" } as const;
const runningFlow = oauthFlow({ prompt });
const stalePoll = deferred<OAuthFlowState>();
const providers = [authProvider("anthropic", "oauth")];
let pollCalls = 0;
const { controller, getState } = createController(
{},
{
authProviders: () => Promise.resolve({ providers }),
startOAuthLogin: () => Promise.resolve(runningFlow),
oauthFlow: () => {
pollCalls += 1;
return pollCalls === 1 ? stalePoll.promise : Promise.resolve(oauthFlow({ status: "cancelled", prompt }));
},
},
);
try {
await controller.openLogin("anthropic");
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(1000);
const dialogAfterPollingStopped = getState().authDialog;
stalePoll.resolve(oauthFlow({ prompt, progress: ["Stale running poll"] }));
await flushMicrotasks();
expect(pollCalls).toBe(2);
expect(dialogAfterPollingStopped).toMatchObject({ step: "oauth", flow: { status: "cancelled" } });
expect(getState().authDialog).toMatchObject({ step: "oauth", flow: { status: "cancelled" } });
} finally {
stalePoll.resolve(runningFlow);
controller.dispose();
vi.unstubAllGlobals();
vi.useRealTimers();
}
});
it("cancels the active OAuth flow and closes the dialog even when cancellation fails", async () => {
const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
const cancelCalls: { flowId: string; machineId: string | undefined }[] = [];
@@ -226,6 +376,13 @@ async function flushMicrotasks(): Promise<void> {
await Promise.resolve();
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolveDeferred: ((value: T) => void) | undefined;
const promise = new Promise<T>((resolve) => { resolveDeferred = resolve; });
if (resolveDeferred === undefined) throw new Error("Deferred promise was not initialized");
return { promise, resolve: resolveDeferred };
}
function remoteMachine(id: string): NonNullable<AppState["selectedMachine"]> {
return {
id,
+67 -23
View File
@@ -1,6 +1,9 @@
import { api as defaultApi, type AuthProviderOption, type AuthType, type OAuthFlowState, type SessionStatus } from "../api";
import type { AuthDialogState } from "../appState";
import { selectedMachineId, type GetState, type SetState } from "./types";
type OAuthDialogState = Extract<AuthDialogState, { step: "oauth" }>;
export interface AuthControllerDependencies {
api?: typeof defaultApi;
pollIntervalMs?: number;
@@ -9,6 +12,8 @@ export interface AuthControllerDependencies {
export class AuthController {
private readonly api: typeof defaultApi;
private readonly pollIntervalMs: number;
private oauthOperationGeneration = 0;
private pollGeneration = 0;
private pollTimer: number | undefined;
constructor(
@@ -22,6 +27,7 @@ export class AuthController {
}
dispose(): void {
this.oauthOperationGeneration += 1;
this.stopPolling();
}
@@ -55,7 +61,7 @@ export class AuthController {
if (dialog?.step !== "providers") return;
const provider = dialog.providers.find((candidate) => candidate.id === providerId && (authType === undefined || candidate.authType === authType));
if (provider === undefined) return;
if (provider.authType === "oauth") await this.startOAuth(provider);
if (provider.authType === "oauth" || provider.loginFlow === "interactive") await this.startLoginFlow(provider);
else this.setState({ authDialog: { step: "apiKey", provider, value: "" } });
}
@@ -128,15 +134,22 @@ export class AuthController {
if (dialog?.step !== "oauth") return;
const request = dialog.flow.prompt ?? dialog.flow.select;
if (request === undefined) return;
const operationGeneration = this.oauthOperationGeneration;
const flowId = dialog.flow.flowId;
const requestId = request.requestId;
const responseValue = value ?? dialog.inputValue ?? "";
const clean = { ...dialog };
delete clean.error;
this.setState({ authDialog: { ...clean, responding: true } });
try {
const flow = await this.api.respondOAuthFlow(dialog.flow.flowId, request.requestId, responseValue, selectedMachineId(this.getState()));
const flow = await this.api.respondOAuthFlow(flowId, requestId, responseValue, selectedMachineId(this.getState()));
const current = this.currentOAuthDialog(operationGeneration, flowId);
if (flow.flowId !== flowId || current === undefined || oauthRequestId(current.flow) !== requestId) return;
this.updateOAuthFlow(flow);
} catch (error) {
this.setState({ authDialog: { ...dialog, responding: false, error: String(error) } });
const current = this.currentOAuthDialog(operationGeneration, flowId);
if (current === undefined || oauthRequestId(current.flow) !== requestId) return;
this.setState({ authDialog: { ...current, responding: false, error: String(error) } });
}
}
@@ -146,16 +159,18 @@ export class AuthController {
this.closeDialog();
return;
}
this.stopPolling();
try {
await this.api.cancelOAuthFlow(dialog.flow.flowId, selectedMachineId(this.getState()));
} catch {
// Best-effort cancel. The dialog closes either way.
}
const flowId = dialog.flow.flowId;
const machineId = selectedMachineId(this.getState());
this.closeDialog();
try {
await this.api.cancelOAuthFlow(flowId, machineId);
} catch {
// Best-effort cancel. The dialog is already closed either way.
}
}
closeDialog(): void {
this.oauthOperationGeneration += 1;
this.stopPolling();
this.setState({ authDialog: undefined });
}
@@ -174,21 +189,27 @@ export class AuthController {
}
const provider = exact[0];
if (provider === undefined) return;
if (provider.authType === "oauth") await this.startOAuth(provider);
if (provider.authType === "oauth" || provider.loginFlow === "interactive") await this.startLoginFlow(provider);
else this.setState({ authDialog: { step: "apiKey", provider, value: "" } });
} catch (error) {
this.setState({ error: String(error) });
}
}
private async startOAuth(provider: AuthProviderOption): Promise<void> {
private async startLoginFlow(provider: AuthProviderOption): Promise<void> {
if (this.rejectRemoteOAuth("login", provider)) return;
const operationGeneration = ++this.oauthOperationGeneration;
this.stopPolling();
try {
const flow = await this.api.startOAuthLogin(provider.id, selectedMachineId(this.getState()));
const machineId = selectedMachineId(this.getState());
const flow = provider.authType === "oauth"
? await this.api.startOAuthLogin(provider.id, machineId)
: await this.api.startInteractiveApiKeyLogin(provider.id, machineId);
if (operationGeneration !== this.oauthOperationGeneration) return;
this.updateOAuthFlow(flow);
this.startPolling(flow.flowId);
if (flow.status === "running") this.startPolling(flow.flowId);
} catch (error) {
this.setState({ error: String(error) });
if (operationGeneration === this.oauthOperationGeneration) this.setState({ error: String(error) });
}
}
@@ -207,11 +228,14 @@ export class AuthController {
void this.refreshStatus();
return;
}
if (flow.status === "error" || flow.status === "cancelled") this.stopPolling();
if (flow.status === "error" || flow.status === "cancelled") {
this.oauthOperationGeneration += 1;
this.stopPolling();
}
const existing = this.getState().authDialog;
const previousInput = existing?.step === "oauth" && existing.flow.flowId === flow.flowId ? existing.inputValue ?? "" : "";
const previousRequestId = existing?.step === "oauth" ? existing.flow.prompt?.requestId ?? existing.flow.select?.requestId : undefined;
const newRequestId = flow.prompt?.requestId ?? flow.select?.requestId;
const previousRequestId = existing?.step === "oauth" ? oauthRequestId(existing.flow) : undefined;
const newRequestId = oauthRequestId(flow);
const sameRequest = previousRequestId !== undefined && previousRequestId === newRequestId;
const inputValue = sameRequest ? previousInput : "";
const responding = sameRequest && existing?.step === "oauth" ? existing.responding === true : false;
@@ -220,29 +244,45 @@ export class AuthController {
private startPolling(flowId: string): void {
this.stopPolling();
this.pollTimer = window.setInterval(() => { void this.poll(flowId); }, this.pollIntervalMs);
const operationGeneration = this.oauthOperationGeneration;
const pollGeneration = this.pollGeneration;
this.pollTimer = window.setInterval(() => { void this.poll(flowId, operationGeneration, pollGeneration); }, this.pollIntervalMs);
}
private stopPolling(): void {
this.pollGeneration += 1;
if (this.pollTimer === undefined) return;
window.clearInterval(this.pollTimer);
this.pollTimer = undefined;
}
private async poll(flowId: string): Promise<void> {
const dialog = this.getState().authDialog;
if (dialog?.step !== "oauth" || dialog.flow.flowId !== flowId) {
private async poll(flowId: string, operationGeneration: number, pollGeneration: number): Promise<void> {
if (pollGeneration !== this.pollGeneration) return;
const dialog = this.currentOAuthDialog(operationGeneration, flowId);
if (dialog === undefined) {
this.stopPolling();
return;
}
const requestId = oauthRequestId(dialog.flow);
try {
this.updateOAuthFlow(await this.api.oauthFlow(flowId, selectedMachineId(this.getState())));
const flow = await this.api.oauthFlow(flowId, selectedMachineId(this.getState()));
const current = this.currentOAuthDialog(operationGeneration, flowId);
if (flow.flowId !== flowId || pollGeneration !== this.pollGeneration || current === undefined || oauthRequestId(current.flow) !== requestId) return;
this.updateOAuthFlow(flow);
} catch (error) {
const current = this.currentOAuthDialog(operationGeneration, flowId);
if (pollGeneration !== this.pollGeneration || current === undefined || oauthRequestId(current.flow) !== requestId) return;
this.stopPolling();
this.setState({ authDialog: { ...dialog, error: String(error) } });
this.setState({ authDialog: { ...current, error: String(error) } });
}
}
private currentOAuthDialog(operationGeneration: number, flowId: string): OAuthDialogState | undefined {
if (operationGeneration !== this.oauthOperationGeneration) return undefined;
const dialog = this.getState().authDialog;
return dialog?.step === "oauth" && dialog.flow.flowId === flowId ? dialog : undefined;
}
private async refreshStatus(): Promise<void> {
const session = this.session();
if (session === undefined) return;
@@ -260,6 +300,10 @@ export class AuthController {
}
}
function oauthRequestId(flow: OAuthFlowState): string | undefined {
return flow.prompt?.requestId ?? flow.select?.requestId;
}
export function parseAuthSlashCommand(text: string): { command: "login" | "logout"; providerId?: string } | undefined {
const trimmed = text.trim();
const match = /^\/(login|logout)(?:\s+(\S+))?\s*$/u.exec(trimmed);
+5 -5
View File
@@ -100,7 +100,7 @@ describe("production native service planning", () => {
wants: [],
prerequisites: [
{ id: "sessiond.command.pi-web-sessiond", kind: "command-available", command: "pi-web-sessiond" },
{ id: "sessiond.node", kind: "node-version", command: "node", minimumMajor: 22 },
{ id: "sessiond.node", kind: "node-version", command: "node", minimumVersion: "22.19.0" },
],
},
{
@@ -111,7 +111,7 @@ describe("production native service planning", () => {
wants: ["sessiond"],
prerequisites: [
{ id: "web.command.pi-web-server", kind: "command-available", command: "pi-web-server" },
{ id: "web.node", kind: "node-version", command: "node", minimumMajor: 22 },
{ id: "web.node", kind: "node-version", command: "node", minimumVersion: "22.19.0" },
],
},
],
@@ -196,7 +196,7 @@ describe("production native service planning", () => {
namedCommandFailure: "command not found",
},
prerequisites: [
{ id: "sessiond.node", kind: "node-version", command: "node", minimumMajor: 22 },
{ id: "sessiond.node", kind: "node-version", command: "node", minimumVersion: "22.19.0" },
{ id: "sessiond.entrypoint", kind: "readable-file", path: "/package with space/sessiond's entry.js" },
],
});
@@ -324,7 +324,7 @@ describe("development native service planning", () => {
environment: { PI_WEB_CONFIG: "/tmp/config.json" },
workingDirectory: "/checkout with space",
prerequisites: [
{ id: "sessiond.node", kind: "node-version", minimumMajor: 22 },
{ id: "sessiond.node", kind: "node-version", minimumVersion: "22.19.0" },
{ id: "sessiond.command.npm", kind: "command-available", command: "npm" },
{ id: "sessiond.package-scripts", kind: "package-scripts", scripts: ["start:sessiond"] },
],
@@ -338,7 +338,7 @@ describe("development native service planning", () => {
after: ["sessiond"],
wants: ["sessiond"],
prerequisites: [
{ id: "uiDev.node", kind: "node-version", minimumMajor: 22 },
{ id: "uiDev.node", kind: "node-version", minimumVersion: "22.19.0" },
{ id: "uiDev.command.npm", kind: "command-available", command: "npm" },
{ id: "uiDev.command.bash", kind: "command-available", command: "bash" },
{ id: "uiDev.package-scripts", kind: "package-scripts", scripts: ["dev:web", "dev:client"] },
+5 -3
View File
@@ -1,3 +1,5 @@
export const minimumSupportedNodeVersion = "22.19.0";
export type NativeServiceBackendKind = "systemd" | "launchd";
export type NativeServiceMode = "production" | "development";
export type NativeServiceId = "sessiond" | "web" | "uiDev";
@@ -64,7 +66,7 @@ export type NativeServicePrerequisite =
id: string;
kind: "node-version";
command: "node";
minimumMajor: number;
minimumVersion: string;
description: string;
}
| {
@@ -571,8 +573,8 @@ function nodeRequirement(serviceId: NativeServiceId): NativeServicePrerequisite
id: `${serviceId}.node`,
kind: "node-version",
command: "node",
minimumMajor: 22,
description: "node >= 22 is available to the service shell",
minimumVersion: minimumSupportedNodeVersion,
description: `node >= ${minimumSupportedNodeVersion} is available to the service shell`,
};
}
+17 -2
View File
@@ -1,3 +1,4 @@
import { spawnSync } from "node:child_process";
import { describe, expect, it, vi } from "vitest";
import {
LaunchdNativeServiceProbe,
@@ -5,6 +6,7 @@ import {
SystemdNativeServiceProbe,
launchdProbePlist,
nativeServicePrerequisiteShellCheck,
nodeVersionCheckScript,
systemdRunArguments,
type LaunchdProbeFileSystem,
type ProbeCommandResult,
@@ -433,13 +435,26 @@ describe("probe service definitions", () => {
id: "sessiond.node",
kind: "node-version",
command: "node",
minimumMajor: 22,
description: "node >= 22",
minimumVersion: "22.19.0",
description: "node >= 22.19.0",
});
expect(check).toContain("\"$pi_web_probe_executable\" '-e'");
expect(check).toContain("22.19.0");
expect(check).not.toContain("&& node -e");
});
it.each([
{ version: "21.99.99", accepted: false },
{ version: "22.18.99", accepted: false },
{ version: "22.19.0", accepted: true },
{ version: "22.19.1", accepted: true },
{ version: "23.0.0", accepted: true },
])("checks the complete Node version for $version", ({ version, accepted }) => {
const result = spawnSync(process.execPath, ["-e", nodeVersionCheckScript("22.19.0"), version]);
expect(result.error).toBeUndefined();
expect(result.status).toBe(accepted ? 0 : 1);
});
it("requires bundled entrypoints to be readable regular files", () => {
const check = nativeServicePrerequisiteShellCheck("bash", {
id: "sessiond.entrypoint",
+8 -5
View File
@@ -434,10 +434,8 @@ export function nativeServicePrerequisiteShellCheck(shell: NativeServiceShellNam
switch (prerequisite.kind) {
case "command-available":
return externalExecutableShellCheck(shell, prerequisite.command);
case "node-version": {
const script = `const major=Number(process.versions.node.split('.')[0]);process.exit(major>=${String(prerequisite.minimumMajor)}?0:1)`;
return externalExecutableShellCheck(shell, "node", ["-e", script]);
}
case "node-version":
return externalExecutableShellCheck(shell, "node", ["-e", nodeVersionCheckScript(prerequisite.minimumVersion)]);
case "readable-file": {
const path = shellQuote(shell, prerequisite.path);
return `test -f ${path} && test -r ${path}`;
@@ -449,6 +447,11 @@ export function nativeServicePrerequisiteShellCheck(shell: NativeServiceShellNam
}
}
export function nodeVersionCheckScript(minimumVersion: string): string {
const encodedMinimum = JSON.stringify(minimumVersion);
return `const version=process.argv[1]??process.versions.node;console.log(process.version);const current=version.split('.').map(Number);const minimum=${encodedMinimum}.split('.').map(Number);const length=Math.max(current.length,minimum.length);let comparison=0;for(let index=0;index<length;index+=1){const left=current[index]??0;const right=minimum[index]??0;if(left!==right){comparison=left>right?1:-1;break}}process.exit(comparison>=0?0:1)`;
}
function externalExecutableShellCheck(
shell: NativeServiceShellName,
command: string,
@@ -518,7 +521,7 @@ function unsatisfiedDetail(prerequisite: NativeServicePrerequisite): string {
case "command-available":
return `${prerequisite.command} did not resolve to an external executable in the native service environment.`;
case "node-version":
return `node >= ${String(prerequisite.minimumMajor)} was not available in the native service environment.`;
return `node >= ${prerequisite.minimumVersion} was not available in the native service environment.`;
case "readable-file":
return `${prerequisite.path} was not a readable regular file in the native service environment.`;
case "package-scripts":
@@ -6,6 +6,7 @@ class FakeSocket extends EventEmitter implements RealtimeSocket {
readonly OPEN = 1;
readyState = this.OPEN;
send = vi.fn();
terminate = vi.fn();
}
describe("SessionEventHub", () => {
@@ -54,6 +55,30 @@ describe("SessionEventHub", () => {
expect(removed.send).not.toHaveBeenCalled();
});
it("terminates a failed session socket without disrupting healthy delivery or sequence watermarks", () => {
const hub = new SessionEventHub();
const failed = new FakeSocket();
const healthy = new FakeSocket();
failed.send.mockImplementation(() => { throw new Error("socket closed"); });
hub.add("s1", failed);
hub.add("s1", healthy);
hub.publish("s1", { type: "assistant.delta", text: "hello" });
expect(failed.send).toHaveBeenCalledOnce();
expect(failed.terminate).toHaveBeenCalledOnce();
expect(healthy.send).toHaveBeenCalledWith(JSON.stringify({ type: "assistant.delta", text: "hello", seq: 1 }));
expect(hub.currentSeq("s1")).toBe(1);
failed.send.mockClear();
hub.publish("s1", { type: "assistant.delta", text: "again" });
expect(failed.send).not.toHaveBeenCalled();
expect(failed.terminate).toHaveBeenCalledOnce();
expect(healthy.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "assistant.delta", text: "again", seq: 2 }));
expect(hub.currentSeq("s1")).toBe(2);
});
it("publishes global events only to global sockets", () => {
const hub = new SessionEventHub();
const globalSocket = new FakeSocket();
@@ -78,6 +103,29 @@ describe("SessionEventHub", () => {
expect(sessionSocket.send).not.toHaveBeenCalled();
});
it("contains termination failures while publishing unstamped global events", () => {
const hub = new SessionEventHub();
const failed = new FakeSocket();
const healthy = new FakeSocket();
failed.send.mockImplementation(() => { throw new Error("socket closed"); });
failed.terminate.mockImplementation(() => { throw new Error("termination failed"); });
hub.addGlobal(failed);
hub.addGlobal(healthy);
hub.publishGlobal({ type: "session.name", sessionId: "s1", name: "Renamed" });
expect(failed.send).toHaveBeenCalledOnce();
expect(failed.terminate).toHaveBeenCalledOnce();
expect(healthy.send).toHaveBeenCalledWith(JSON.stringify({ type: "session.name", sessionId: "s1", name: "Renamed" }));
failed.send.mockClear();
hub.publishGlobal({ type: "session.name", sessionId: "s1", name: "Renamed again" });
expect(failed.send).not.toHaveBeenCalled();
expect(failed.terminate).toHaveBeenCalledOnce();
expect(healthy.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "session.name", sessionId: "s1", name: "Renamed again" }));
});
it("stamps a monotonically increasing per-session seq on published events", () => {
const hub = new SessionEventHub();
const socket = new FakeSocket();
+19 -5
View File
@@ -5,6 +5,7 @@ export interface RealtimeSocket {
readonly OPEN: number;
readyState: number;
send(payload: string): void;
terminate(): void;
on(event: "close", listener: () => void): unknown;
}
@@ -34,9 +35,7 @@ export class SessionEventHub {
const seq = (this.seqBySession.get(sessionId) ?? 0) + 1;
this.seqBySession.set(sessionId, seq);
const payload = JSON.stringify({ ...projectBrowserSessionEvent(event), seq });
for (const socket of this.socketsBySession.get(sessionId) ?? []) {
if (socket.readyState === socket.OPEN) socket.send(payload);
}
this.sendToSockets(this.socketsBySession.get(sessionId), payload);
}
/**
@@ -55,8 +54,23 @@ export class SessionEventHub {
publishRealtime(event: RealtimeEvent): void {
const payload = JSON.stringify(event);
for (const socket of this.globalSockets) {
if (socket.readyState === socket.OPEN) socket.send(payload);
this.sendToSockets(this.globalSockets, payload);
}
private sendToSockets(sockets: Set<RealtimeSocket> | undefined, payload: string): void {
if (sockets === undefined) return;
for (const socket of sockets) {
if (socket.readyState !== socket.OPEN) continue;
try {
socket.send(payload);
} catch {
sockets.delete(socket);
try {
socket.terminate();
} catch {
// Removal is authoritative; cleanup failure must not block healthy sockets.
}
}
}
}
}
+3 -3
View File
@@ -36,15 +36,15 @@ await app.register(fastifyWebsocket);
await runSessionDaemonStartup({
logger: app.log,
createRuntime() {
async createRuntime() {
const eventHub = new SessionEventHub();
const workspaceActivity = new WorkspaceActivityService(eventHub);
const auth = new AuthService({ agentDir: activeAgentProfile.dir });
const auth = await AuthService.create({ agentDir: activeAgentProfile.dir, logger: app.log });
const spawnTargets = config.spawnSessions
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
: undefined;
const sessions = new PiSessionService(eventHub, {
modelRegistry: auth.modelRegistry,
modelRuntime: auth.runtime,
agentDir: activeAgentProfile.dir,
workspaceActivity,
logger: app.log,
+2 -2
View File
@@ -12,7 +12,7 @@ export interface SessionDaemonStartupLogger {
export interface SessionDaemonStartupSteps<Runtime> {
logger: SessionDaemonStartupLogger;
createRuntime(): Runtime;
createRuntime(): Runtime | Promise<Runtime>;
registerRoutes(runtime: Runtime): void;
listen(runtime: Runtime): Promise<void>;
migrateArchive?: () => Promise<LegacySessionArchiveMigrationResult>;
@@ -36,7 +36,7 @@ export async function runSessionDaemonStartup<Runtime>(
);
}
const runtime = steps.createRuntime();
const runtime = await steps.createRuntime();
steps.registerRoutes(runtime);
await steps.listen(runtime);
return runtime;
+51 -33
View File
@@ -1,52 +1,70 @@
import { describe, expect, it } from "vitest";
import { getLoginProviderOptions, getLogoutProviderOptions, isApiKeyLoginProvider, type AuthProviderModelRegistry } from "./authProviderOptions";
import { getLoginProviderOptions, getLogoutProviderOptions, type AuthProviderRuntime } from "./authProviderOptions";
function registry(): AuthProviderModelRegistry {
const credentials = new Map<string, { type: "oauth" | "api_key" }>();
credentials.set("openai", { type: "api_key" });
function runtime(configuredProviders: ReadonlySet<string> = new Set(["openai"])): AuthProviderRuntime {
const credentials = [{ providerId: "openai", type: "api_key" as const }];
// Auth shapes mirror what the Pi SDK actually reports for these providers:
// github-copilot supports both methods, openai-codex is OAuth-only, and
// ambient providers resolve credentials without offering interactive login.
const providers = [
{ id: "anthropic", name: "Anthropic", auth: { oauth: {}, apiKey: { login: () => undefined } } },
{ id: "github-copilot", name: "GitHub Copilot", auth: { oauth: {}, apiKey: { login: () => undefined } } },
{ id: "openai-codex", name: "ChatGPT Plus/Pro (Codex Subscription)", auth: { oauth: {} } },
{ id: "openai", name: "OpenAI", auth: { apiKey: { login: () => undefined } } },
{ id: "custom", name: "Custom", auth: { apiKey: { login: () => undefined } } },
{ id: "cloudflare-ai-gateway", name: "Cloudflare AI Gateway", auth: { apiKey: { login: () => undefined } } },
{ id: "cloudflare-workers-ai", name: "Cloudflare Workers AI", auth: { apiKey: { login: () => undefined } } },
{ id: "amazon-bedrock", name: "Amazon Bedrock", auth: { apiKey: { login: () => undefined } } },
{ id: "google-vertex", name: "Google Vertex AI", auth: { apiKey: { login: () => undefined } } },
{ id: "ambient", name: "Ambient credentials", auth: { apiKey: {} } },
];
return {
authStorage: {
getOAuthProviders: () => [
{ id: "anthropic", name: "Anthropic (Claude Pro/Max)" },
{ id: "github-copilot", name: "GitHub Copilot" },
{ id: "openai-codex", name: "ChatGPT Plus/Pro (Codex Subscription)" },
],
list: () => Array.from(credentials.keys()),
get: (provider: string) => credentials.get(provider),
},
getAll: () => [
{ provider: "anthropic" },
{ provider: "openai" },
{ provider: "openai-codex" },
{ provider: "github-copilot" },
{ provider: "custom" },
],
getProviderDisplayName: (provider: string) => ({ anthropic: "Anthropic", openai: "OpenAI", custom: "Custom" }[provider] ?? provider),
getProviders: () => providers,
listCredentials: () => Promise.resolve(credentials),
getProviderAuthStatus: (provider: string) => (provider === "openai" ? { configured: true, source: "stored" } : { configured: false }),
hasConfiguredAuth: (provider: string) => configuredProviders.has(provider),
};
}
describe("auth provider options", () => {
it("keeps OAuth-only providers out of API key login options", () => {
expect(isApiKeyLoginProvider("openai-codex", new Set(["openai-codex"]))).toBe(false);
expect(isApiKeyLoginProvider("github-copilot", new Set(["github-copilot"]))).toBe(false);
expect(isApiKeyLoginProvider("openai", new Set(["openai-codex"]))).toBe(true);
});
it("builds login options for OAuth-only, dual-auth, and API-key providers", () => {
const options = getLoginProviderOptions(registry());
it("offers each interactive login method reported by the backend", () => {
const options = getLoginProviderOptions(runtime());
expect(options).toEqual(expect.arrayContaining([
// Dual-capable providers surface both login methods, driven purely by SDK data.
expect.objectContaining({ id: "anthropic", authType: "oauth" }),
expect.objectContaining({ id: "anthropic", authType: "api_key" }),
expect.objectContaining({ id: "openai", authType: "api_key", status: { configured: true, source: "stored" } }),
expect.objectContaining({ id: "github-copilot", authType: "oauth" }),
expect.objectContaining({ id: "github-copilot", authType: "api_key" }),
// OAuth-only provider surfaces only oauth.
expect.objectContaining({ id: "openai-codex", authType: "oauth" }),
// API-key options use the generic AuthInteraction flow, including
// multi-field and select-first providers the legacy form cannot execute.
expect.objectContaining({ id: "openai", authType: "api_key", loginFlow: "interactive", status: { configured: true, source: "stored" } }),
expect.objectContaining({ id: "custom", authType: "api_key", loginFlow: "interactive" }),
expect.objectContaining({ id: "cloudflare-ai-gateway", authType: "api_key", loginFlow: "interactive" }),
expect.objectContaining({ id: "cloudflare-workers-ai", authType: "api_key", loginFlow: "interactive" }),
expect.objectContaining({ id: "amazon-bedrock", authType: "api_key", loginFlow: "interactive" }),
expect.objectContaining({ id: "google-vertex", authType: "api_key", loginFlow: "interactive" }),
]));
expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai-codex", authType: "api_key" })]));
expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai", authType: "oauth" })]));
expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "ambient", authType: "api_key" })]));
});
it("returns only currently stored credentials for logout", () => {
expect(getLogoutProviderOptions(registry())).toEqual([
expect.objectContaining({ id: "openai", authType: "api_key" }),
it("does not report a stored credential as configured when provider resolution is incomplete", async () => {
const unresolvedRuntime = runtime(new Set());
expect(getLoginProviderOptions(unresolvedRuntime, "api_key")).toEqual(expect.arrayContaining([
expect.objectContaining({ id: "openai", status: { configured: false } }),
]));
expect(await getLogoutProviderOptions(unresolvedRuntime)).toEqual([
expect.objectContaining({ id: "openai", authType: "api_key", status: { configured: false } }),
]);
});
it("returns only currently stored credentials for logout", async () => {
expect(await getLogoutProviderOptions(runtime())).toEqual([
expect.objectContaining({ id: "openai", authType: "api_key", status: { configured: true, source: "stored" } }),
]);
});
});
+54 -38
View File
@@ -1,62 +1,78 @@
import type { AuthProviderOption, AuthProviderStatus, AuthType } from "../../shared/apiTypes.js";
const OAUTH_ONLY_PROVIDERS = new Set(["github-copilot", "openai-codex"]);
export interface AuthProviderModelRegistry {
authStorage: {
getOAuthProviders(): { id: string; name: string }[];
list(): string[];
get(provider: string): { type: AuthType } | undefined;
};
getAll(): { provider: string }[];
getProviderDisplayName(provider: string): string;
getProviderAuthStatus(provider: string): AuthProviderStatus;
/** Minimal provider shape needed to enumerate login/logout options. */
interface AuthProviderInfo {
id: string;
name: string;
auth: { apiKey?: { login?: unknown }; oauth?: unknown };
}
export function getLoginProviderOptions(modelRegistry: AuthProviderModelRegistry, authType?: AuthType): AuthProviderOption[] {
const oauthProviders = modelRegistry.authStorage.getOAuthProviders();
const oauthProviderIds = new Set(oauthProviders.map((provider) => provider.id));
const options: AuthProviderOption[] = oauthProviders.map((provider) => ({
id: provider.id,
name: provider.name,
authType: "oauth",
status: modelRegistry.getProviderAuthStatus(provider.id),
}));
/** Non-secret stored-credential metadata, keyed by provider id. */
interface AuthProviderCredentialInfo {
providerId: string;
type: AuthType;
}
const modelProviders = new Set(modelRegistry.getAll().map((model) => model.provider));
for (const providerId of modelProviders) {
if (!isApiKeyLoginProvider(providerId, oauthProviderIds)) continue;
/**
* Structural slice of the SDK `ModelRuntime` used to derive auth provider
* options. Kept structural (rather than `Pick<ModelRuntime, ...>`) so tests can
* supply a lightweight double without constructing a full runtime; a real
* `ModelRuntime` satisfies it.
*/
export interface AuthProviderRuntime {
getProviders(): readonly AuthProviderInfo[];
listCredentials(): Promise<readonly AuthProviderCredentialInfo[]>;
getProviderAuthStatus(providerId: string): AuthProviderStatus;
hasConfiguredAuth(providerId: string): boolean;
}
export function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?: AuthType): AuthProviderOption[] {
const providers = runtime.getProviders();
const options: AuthProviderOption[] = [];
for (const provider of providers) {
if (provider.auth.oauth === undefined) continue;
options.push({
id: providerId,
name: modelRegistry.getProviderDisplayName(providerId),
id: provider.id,
name: provider.name,
authType: "oauth",
status: truthfulProviderStatus(runtime, provider.id),
});
}
for (const provider of providers) {
if (provider.auth.apiKey?.login === undefined) continue;
options.push({
id: provider.id,
name: provider.name,
authType: "api_key",
status: modelRegistry.getProviderAuthStatus(providerId),
status: truthfulProviderStatus(runtime, provider.id),
loginFlow: "interactive",
});
}
return filterAndSort(options, authType);
}
export function getLogoutProviderOptions(modelRegistry: AuthProviderModelRegistry): AuthProviderOption[] {
export async function getLogoutProviderOptions(runtime: AuthProviderRuntime): Promise<AuthProviderOption[]> {
const providerNames = new Map(runtime.getProviders().map((provider) => [provider.id, provider.name]));
const options: AuthProviderOption[] = [];
for (const providerId of modelRegistry.authStorage.list()) {
const credential = modelRegistry.authStorage.get(providerId);
if (credential === undefined) continue;
for (const credential of await runtime.listCredentials()) {
options.push({
id: providerId,
name: modelRegistry.getProviderDisplayName(providerId),
id: credential.providerId,
name: providerNames.get(credential.providerId) ?? credential.providerId,
authType: credential.type,
status: modelRegistry.getProviderAuthStatus(providerId),
status: truthfulProviderStatus(runtime, credential.providerId),
});
}
return filterAndSort(options);
}
export function isApiKeyLoginProvider(providerId: string, oauthProviderIds: ReadonlySet<string>): boolean {
if (OAUTH_ONLY_PROVIDERS.has(providerId)) return false;
if (providerId === "anthropic") return true;
if (oauthProviderIds.has(providerId)) return false;
return true;
function truthfulProviderStatus(runtime: AuthProviderRuntime, providerId: string): AuthProviderStatus {
const reported = runtime.getProviderAuthStatus(providerId);
// ModelRuntime reports any stored entry as configured before checking whether
// the provider can resolve all required credential and ambient fields.
return reported.configured && !runtime.hasConfiguredAuth(providerId) ? { configured: false } : reported;
}
function filterAndSort(options: AuthProviderOption[], authType?: AuthType): AuthProviderOption[] {
+14 -4
View File
@@ -4,7 +4,7 @@ import type { AuthService } from "./authService.js";
export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, prefix = ""): void {
app.get<{ Querystring: { mode?: "login" | "logout"; authType?: "oauth" | "api_key" } }>(`${prefix}/auth/providers`, async (request, reply) => {
try {
return auth.authProviders(request.query.mode ?? "login", request.query.authType);
return await auth.authProviders(request.query.mode ?? "login", request.query.authType);
} catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
}
@@ -12,7 +12,17 @@ export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, pref
app.post<{ Body: { providerId: string; key: string } }>(`${prefix}/auth/api-key`, async (request, reply) => {
try {
return auth.saveApiKey(request.body.providerId, request.body.key);
return await auth.saveApiKey(request.body.providerId, request.body.key);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
// Additive endpoint for newer browsers; the one-secret route remains for
// rolling compatibility with older browser bundles.
app.post<{ Body: { providerId: string } }>(`${prefix}/auth/api-key/interactive`, async (request, reply) => {
try {
return await auth.startApiKeyLogin(request.body.providerId);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
@@ -20,7 +30,7 @@ export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, pref
app.post<{ Body: { providerId: string } }>(`${prefix}/auth/logout`, async (request, reply) => {
try {
return auth.logoutProvider(request.body.providerId);
return await auth.logoutProvider(request.body.providerId);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
@@ -28,7 +38,7 @@ export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, pref
app.post<{ Body: { providerId: string } }>(`${prefix}/auth/oauth`, async (request, reply) => {
try {
return auth.startOAuthLogin(request.body.providerId);
return await auth.startOAuthLogin(request.body.providerId);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
+464 -35
View File
@@ -1,98 +1,505 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { InMemoryCredentialStore, type AuthPrompt, type Credential } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OAuthFlowState } from "../../shared/apiTypes.js";
import { AuthService, type AuthChange } from "./authService.js";
import { AuthService, createModelRuntimeForAgentDir, type AuthChange, type AuthServiceLogger } from "./authService.js";
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
const tempDirs: string[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe("AuthService", () => {
it("saves API keys and emits a global auth change", () => {
const { auth, authStorage, changes } = createAuthService();
it("saves API keys and emits a global auth change after the runtime refreshes", async () => {
const { auth, runtime, credentials, changes } = await createAuthService();
const reloadConfig = vi.spyOn(runtime, "reloadConfig").mockResolvedValue(undefined);
const refresh = vi.spyOn(runtime, "refresh");
expect(auth.saveApiKey("anthropic", "sk-test")).toEqual({ accepted: true });
await expect(auth.saveApiKey("anthropic", "sk-test")).resolves.toEqual({ accepted: true });
expect(authStorage.get("anthropic")).toEqual({ type: "api_key", key: "sk-test" });
await expect(credentials.read("anthropic")).resolves.toEqual({ type: "api_key", key: "sk-test" });
expect(reloadConfig).toHaveBeenCalledOnce();
expect(refresh).toHaveBeenCalledOnce();
expect(changes).toEqual([{}]);
auth.dispose();
});
it("logs out providers and emits the removed provider id", () => {
const { auth, authStorage, changes } = createAuthService({ anthropic: { type: "api_key", key: "sk-test" } });
it("logs out providers and emits the removed provider id after the runtime refreshes", async () => {
const { auth, runtime, credentials, changes } = await createAuthService({ anthropic: { type: "api_key", key: "sk-test" } });
const refresh = vi.spyOn(runtime, "refresh");
expect(auth.logoutProvider("anthropic")).toEqual({ accepted: true });
await expect(auth.logoutProvider("anthropic")).resolves.toEqual({ accepted: true });
expect(authStorage.get("anthropic")).toBeUndefined();
await expect(credentials.read("anthropic")).resolves.toBeUndefined();
expect(refresh).toHaveBeenCalledOnce();
expect(changes).toEqual([{ removedProviderId: "anthropic" }]);
auth.dispose();
});
it("rejects blank API keys", () => {
const { auth, changes } = createAuthService();
it("persists an API key and attempts every listener when failure logging throws", async () => {
const loggingFailure = new Error("auth logger failed");
const error = vi.fn(() => { throw loggingFailure; });
const logger: AuthServiceLogger = { error };
const { auth, credentials, changes } = await createAuthService({}, logger);
const failure = new Error("session auth refresh failed");
const attempts: string[] = [];
auth.subscribe(() => {
attempts.push("throwing");
throw failure;
});
auth.subscribe(async () => {
await Promise.resolve();
attempts.push("healthy");
});
expect(() => { auth.saveApiKey("anthropic", " "); }).toThrow("API key is required");
await expect(auth.saveApiKey("anthropic", "sk-test")).resolves.toEqual({ accepted: true });
await expect(credentials.read("anthropic")).resolves.toEqual({ type: "api_key", key: "sk-test" });
expect(changes).toEqual([{}]);
expect(attempts).toEqual(["throwing", "healthy"]);
expect(error).toHaveBeenCalledWith(
{ err: failure, operation: "login", providerId: "anthropic", authType: "api_key" },
"auth-change listener failed",
);
auth.dispose();
});
it("removes a credential when auth-change propagation rejects", async () => {
const error = vi.fn();
const logger: AuthServiceLogger = { error };
const { auth, credentials, changes } = await createAuthService(
{ anthropic: { type: "api_key", key: "sk-test" } },
logger,
);
const failure = new Error("session logout refresh failed");
auth.subscribe(() => Promise.reject(failure));
await expect(auth.logoutProvider("anthropic")).resolves.toEqual({ accepted: true });
await expect(credentials.read("anthropic")).resolves.toBeUndefined();
expect(changes).toEqual([{ removedProviderId: "anthropic" }]);
expect(error).toHaveBeenCalledWith(
{ err: failure, operation: "logout", providerId: "anthropic" },
"auth-change listener failed",
);
auth.dispose();
});
it("rejects blank API keys", async () => {
const { auth, changes } = await createAuthService();
await expect(auth.saveApiKey("anthropic", " ")).rejects.toThrow("API key is required");
expect(changes).toEqual([]);
auth.dispose();
});
it("keeps existing file-backed credentials unchanged when legacy Cloudflare setup cannot finish", async () => {
const seed = {
"cloudflare-ai-gateway": {
type: "api_key" as const,
key: "existing-secret",
env: { CLOUDFLARE_ACCOUNT_ID: "existing-account", CLOUDFLARE_GATEWAY_ID: "existing-gateway" },
},
};
const { auth, authPath, changes } = await createFileBackedAuthService(seed);
const before = await readFile(authPath, "utf8");
await expect(auth.saveApiKey("cloudflare-ai-gateway", "new-secret")).rejects.toThrow(
"Cloudflare AI Gateway requires interactive setup; use Pi's generic /login flow",
);
await expect(readFile(authPath, "utf8")).resolves.toBe(before);
expect(changes).toEqual([]);
auth.dispose();
});
it.each([
{ providerId: "amazon-bedrock", providerName: "Amazon Bedrock" },
{ providerId: "google-vertex", providerName: "Google Vertex AI" },
])("keeps an empty file-backed store unchanged when legacy $providerName setup starts with a selection", async ({ providerId, providerName }) => {
const { auth, authPath, changes } = await createFileBackedAuthService({});
const before = await readFile(authPath, "utf8");
await expect(auth.saveApiKey(providerId, "submitted-secret")).rejects.toThrow(
`${providerName} requires interactive setup; use Pi's generic /login flow`,
);
await expect(readFile(authPath, "utf8")).resolves.toBe(before);
expect(changes).toEqual([]);
auth.dispose();
});
it("executes Cloudflare multi-field API-key setup through the interactive flow", async () => {
const { auth, credentials, changes } = await createAuthService();
const state = await auth.startApiKeyLogin("cloudflare-ai-gateway");
expect(state.prompt).toMatchObject({ message: "Enter Cloudflare API key", promptType: "secret" });
if (state.prompt === undefined) throw new Error("Expected Cloudflare key prompt");
auth.respondToOAuthFlow(state.flowId, state.prompt.requestId, "cf-secret");
await vi.waitFor(() => {
expect(auth.oauthFlow(state.flowId).prompt).toMatchObject({ message: "Enter Cloudflare account ID", promptType: "text" });
});
const accountPrompt = auth.oauthFlow(state.flowId).prompt;
if (accountPrompt === undefined) throw new Error("Expected Cloudflare account prompt");
auth.respondToOAuthFlow(state.flowId, accountPrompt.requestId, "account-1");
await vi.waitFor(() => {
expect(auth.oauthFlow(state.flowId).prompt).toMatchObject({ message: "Enter Cloudflare AI Gateway ID", promptType: "text" });
});
const gatewayPrompt = auth.oauthFlow(state.flowId).prompt;
if (gatewayPrompt === undefined) throw new Error("Expected Cloudflare gateway prompt");
auth.respondToOAuthFlow(state.flowId, gatewayPrompt.requestId, "gateway-1");
await vi.waitFor(() => { expect(auth.oauthFlow(state.flowId).status).toBe("complete"); });
await expect(credentials.read("cloudflare-ai-gateway")).resolves.toEqual({
type: "api_key",
key: "cf-secret",
env: { CLOUDFLARE_ACCOUNT_ID: "account-1", CLOUDFLARE_GATEWAY_ID: "gateway-1" },
});
expect(changes).toEqual([{}]);
auth.dispose();
});
it.each([
{ providerId: "amazon-bedrock", selection: "bearer-token", secretPrompt: "Enter Amazon Bedrock bearer token" },
{ providerId: "google-vertex", selection: "api-key", secretPrompt: "Enter Google Cloud API key" },
])("executes $providerId select-first API-key setup through the interactive flow", async ({ providerId, selection, secretPrompt }) => {
const { auth, credentials, changes } = await createAuthService();
const state = await auth.startApiKeyLogin(providerId);
expect(state.select).toBeDefined();
if (state.select === undefined) throw new Error("Expected auth method selection");
auth.respondToOAuthFlow(state.flowId, state.select.requestId, selection);
await vi.waitFor(() => {
expect(auth.oauthFlow(state.flowId).prompt).toMatchObject({ message: secretPrompt, promptType: "secret" });
});
const prompt = auth.oauthFlow(state.flowId).prompt;
if (prompt === undefined) throw new Error("Expected provider secret prompt");
auth.respondToOAuthFlow(state.flowId, prompt.requestId, "provider-secret");
await vi.waitFor(() => { expect(auth.oauthFlow(state.flowId).status).toBe("complete"); });
await expect(credentials.read(providerId)).resolves.toEqual({ type: "api_key", key: "provider-secret" });
expect(changes).toEqual([{}]);
auth.dispose();
});
it("reports a key-only legacy Cloudflare credential as unconfigured", async () => {
vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "");
vi.stubEnv("CLOUDFLARE_GATEWAY_ID", "");
const { auth } = await createFileBackedAuthService({
"cloudflare-ai-gateway": { type: "api_key", key: "legacy-secret" },
});
const response = await auth.authProviders("login", "api_key");
expect(response.providers).toEqual(expect.arrayContaining([
expect.objectContaining({
id: "cloudflare-ai-gateway",
loginFlow: "interactive",
status: { configured: false },
}),
]));
auth.dispose();
});
it("reports a stored Cloudflare key as configured when ambient fields complete it", async () => {
vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "ambient-account");
vi.stubEnv("CLOUDFLARE_GATEWAY_ID", "ambient-gateway");
const { auth } = await createFileBackedAuthService({
"cloudflare-ai-gateway": { type: "api_key", key: "legacy-secret" },
});
const response = await auth.authProviders("login", "api_key");
expect(response.providers).toEqual(expect.arrayContaining([
expect.objectContaining({
id: "cloudflare-ai-gateway",
loginFlow: "interactive",
status: { configured: true, source: "stored" },
}),
]));
auth.dispose();
});
it.each([
{ label: "text", prompt: { type: "text", message: "Account" } satisfies AuthPrompt },
{
label: "select",
prompt: { type: "select", message: "Region", options: [{ id: "us", label: "US" }] } satisfies AuthPrompt,
},
{ label: "manual-code", prompt: { type: "manual_code", message: "Code" } satisfies AuthPrompt },
])("rejects a first $label prompt before credential persistence", async ({ prompt }) => {
const { auth, runtime, credentials, changes } = await createAuthService();
const login = mockLoginPromptsBeforePersistence(runtime, credentials, [prompt]);
await expect(auth.saveApiKey("anthropic", "sk-test")).rejects.toThrow(
"Anthropic requires interactive setup; use Pi's generic /login flow",
);
expect(login).toHaveBeenCalledOnce();
await expect(credentials.read("anthropic")).resolves.toBeUndefined();
expect(changes).toEqual([]);
auth.dispose();
});
it("rejects a repeated secret prompt before credential persistence", async () => {
const { auth, runtime, credentials, changes } = await createAuthService();
const login = mockLoginPromptsBeforePersistence(runtime, credentials, [
{ type: "secret", message: "API key" },
{ type: "secret", message: "API key again" },
]);
await expect(auth.saveApiKey("anthropic", "sk-test")).rejects.toThrow(
"Anthropic requires interactive setup; use Pi's generic /login flow",
);
expect(login).toHaveBeenCalledOnce();
await expect(credentials.read("anthropic")).resolves.toBeUndefined();
expect(changes).toEqual([]);
auth.dispose();
});
it("rejects an aborted secret prompt before credential persistence", async () => {
const { auth, runtime, credentials, changes } = await createAuthService();
const abort = new AbortController();
abort.abort();
const login = mockLoginPromptsBeforePersistence(runtime, credentials, [
{ type: "secret", message: "API key", signal: abort.signal },
]);
await expect(auth.saveApiKey("anthropic", "sk-test")).rejects.toThrow("Login cancelled");
expect(login).toHaveBeenCalledOnce();
await expect(credentials.read("anthropic")).resolves.toBeUndefined();
expect(changes).toEqual([]);
auth.dispose();
});
it("rejects unknown providers before starting API-key login", async () => {
const { auth, runtime, credentials, changes } = await createAuthService();
const login = vi.spyOn(runtime, "login");
await expect(auth.saveApiKey("unknown-provider", "sk-test")).rejects.toThrow(
"API key provider not found: unknown-provider",
);
expect(login).not.toHaveBeenCalled();
await expect(credentials.read("unknown-provider")).resolves.toBeUndefined();
expect(changes).toEqual([]);
auth.dispose();
});
it("rejects ambient-only providers before starting API-key login", async () => {
const { auth, runtime, credentials, changes } = await createAuthService();
const providers = [...runtime.getProviders()];
const interactiveProvider = providers.find((provider) => provider.auth.apiKey?.login !== undefined);
if (interactiveProvider?.auth.apiKey === undefined) throw new Error("Expected an interactive API-key provider");
const ambientApiKey = { ...interactiveProvider.auth.apiKey };
delete ambientApiKey.login;
const ambientProvider = {
...interactiveProvider,
id: "ambient-only",
name: "Ambient Only",
auth: { apiKey: ambientApiKey },
};
vi.spyOn(runtime, "getProviders").mockReturnValue([...providers, ambientProvider]);
const login = vi.spyOn(runtime, "login");
await expect(auth.saveApiKey("ambient-only", "sk-test")).rejects.toThrow(
"Ambient Only does not support interactive API-key setup",
);
expect(login).not.toHaveBeenCalled();
await expect(credentials.read("ambient-only")).resolves.toBeUndefined();
expect(changes).toEqual([]);
auth.dispose();
});
it("reloads models.json before enumerating and validating OAuth providers", async () => {
const agentDir = await tempAgentDir();
const modelsPath = join(agentDir, "models.json");
const runtime = await ModelRuntime.create({
credentials: new InMemoryCredentialStore(),
modelsPath,
allowModelNetwork: false,
});
const authFlows = new CapturingOAuthLoginFlowService();
const auth = await AuthService.create({ runtime, authFlows });
await writeFile(modelsPath, radiusModelsConfig("First Radius"));
const response = await auth.authProviders("login", "oauth");
expect(response.providers).toEqual(expect.arrayContaining([
expect.objectContaining({ id: "test-radius", name: "First Radius", authType: "oauth" }),
]));
await writeFile(modelsPath, radiusModelsConfig("Updated Radius"));
await expect(auth.startOAuthLogin("test-radius")).resolves.toMatchObject({
providerId: "test-radius",
providerName: "Updated Radius",
status: "running",
});
expect(authFlows.startCalls.at(0)).toMatchObject({
providerId: "test-radius",
providerName: "Updated Radius",
runtime,
});
auth.dispose();
});
it("stores credentials in the configured agent directory", async () => {
const agentDir = await tempAgentDir();
const auth = new AuthService({ agentDir });
const runtime = await createModelRuntimeForAgentDir(agentDir, false);
const auth = await AuthService.create({ runtime });
auth.saveApiKey("anthropic", "sk-test");
await auth.saveApiKey("anthropic", "sk-test");
await expect(readFile(join(agentDir, "auth.json"), "utf8")).resolves.toContain("sk-test");
auth.dispose();
});
it("refreshes auth state after OAuth login completes", () => {
const authStorage = AuthStorage.inMemory();
const modelRegistry = ModelRegistry.create(authStorage);
it("reconciles cancellation after ModelRuntime persists OAuth but before its refresh completes", async () => {
const { auth, runtime, credentials, changes } = await createAuthService();
const provider = runtime.getProviders().find((option) => option.id === "anthropic" && option.auth.oauth !== undefined);
if (provider?.auth.oauth === undefined) throw new Error("Expected built-in OAuth provider");
const credential: Credential = {
type: "oauth",
refresh: "refresh-token",
access: "access-token",
expires: Date.now() + 60_000,
};
vi.spyOn(provider.auth.oauth, "login").mockResolvedValue(credential);
vi.spyOn(runtime, "reloadConfig").mockResolvedValue(undefined);
const refreshStarted = deferred<undefined>();
const finishRefresh = deferred<undefined>();
const refresh = vi.spyOn(runtime, "refresh").mockImplementation(async () => {
refreshStarted.resolve(undefined);
await finishRefresh.promise;
return { aborted: false, errors: new Map() };
});
const state = await auth.startOAuthLogin(provider.id);
await refreshStarted.promise;
await expect(credentials.read(provider.id)).resolves.toEqual(credential);
expect(auth.cancelOAuthFlow(state.flowId)).toMatchObject({ status: "cancelled", error: "Login cancelled" });
expect(changes).toEqual([]);
finishRefresh.resolve(undefined);
await vi.waitFor(() => { expect(auth.oauthFlow(state.flowId).status).toBe("complete"); });
expect(auth.oauthFlow(state.flowId)).toMatchObject({ status: "complete", progress: ["Login complete"] });
expect(auth.oauthFlow(state.flowId)).not.toHaveProperty("error");
await expect(credentials.read(provider.id)).resolves.toEqual(credential);
expect(changes).toEqual([{}]);
expect(refresh).toHaveBeenCalledOnce();
auth.dispose();
});
it("emits an auth change after OAuth login completes without refreshing twice", async () => {
const runtime = await ModelRuntime.create({
credentials: new InMemoryCredentialStore(),
modelsPath: null,
allowModelNetwork: false,
});
const authFlows = new CapturingOAuthLoginFlowService();
const auth = new AuthService({ modelRegistry, authFlows });
const auth = await AuthService.create({ runtime, authFlows });
const changes: AuthChange[] = [];
auth.subscribe((change) => { changes.push(change); });
const reload = vi.spyOn(authStorage, "reload");
const refresh = vi.spyOn(modelRegistry, "refresh");
const provider = authStorage.getOAuthProviders().find((option) => option.id === "anthropic");
const refresh = vi.spyOn(runtime, "refresh");
const provider = runtime.getProviders().find((option) => option.id === "anthropic" && option.auth.oauth !== undefined);
if (provider === undefined) throw new Error("Expected built-in OAuth provider");
expect(auth.startOAuthLogin(provider.id)).toMatchObject({ providerId: provider.id, providerName: provider.name, status: "running" });
await expect(auth.startOAuthLogin(provider.id)).resolves.toMatchObject({ providerId: provider.id, providerName: provider.name, status: "running" });
const startOptions = authFlows.startCalls.at(0);
if (startOptions === undefined) throw new Error("Expected OAuth flow to start");
expect(startOptions.providerId).toBe(provider.id);
expect(startOptions.providerName).toBe(provider.name);
expect(startOptions.authStorage).toBe(authStorage);
expect(startOptions.runtime).toBe(runtime);
expect(changes).toEqual([]);
reload.mockClear();
refresh.mockClear();
if (startOptions.onComplete === undefined) throw new Error("Expected OAuth completion callback");
startOptions.onComplete();
expect(reload).toHaveBeenCalledOnce();
expect(refresh).toHaveBeenCalledOnce();
await startOptions.onComplete();
expect(changes).toEqual([{}]);
expect(refresh).not.toHaveBeenCalled();
auth.dispose();
expect(authFlows.disposed).toBe(true);
});
it("completes OAuth when an auth-change listener and failure logging throw", async () => {
const loggingFailure = new Error("auth logger failed");
const error = vi.fn(() => { throw loggingFailure; });
const logger: AuthServiceLogger = { error };
const { auth, runtime, changes } = await createAuthService({}, logger);
const provider = runtime.getProviders().find((option) => option.id === "anthropic" && option.auth.oauth !== undefined);
if (provider === undefined) throw new Error("Expected built-in OAuth provider");
vi.spyOn(runtime, "login").mockResolvedValue({
type: "oauth",
refresh: "refresh-token",
access: "access-token",
expires: Date.now() + 60_000,
});
const failure = new Error("session OAuth refresh failed");
auth.subscribe(() => Promise.reject(failure));
const state = await auth.startOAuthLogin(provider.id);
await vi.waitFor(() => { expect(auth.oauthFlow(state.flowId).status).toBe("complete"); });
expect(changes).toEqual([{}]);
expect(error).toHaveBeenCalledWith(
{ err: failure, operation: "login", providerId: provider.id, authType: "oauth" },
"auth-change listener failed",
);
auth.dispose();
});
});
function createAuthService(data: Parameters<typeof AuthStorage.inMemory>[0] = {}) {
const authStorage = AuthStorage.inMemory(data);
const modelRegistry = ModelRegistry.create(authStorage);
const auth = new AuthService({ modelRegistry });
async function createAuthService(seed: Record<string, Credential> = {}, logger?: AuthServiceLogger) {
const credentials = new InMemoryCredentialStore();
for (const [providerId, credential] of Object.entries(seed)) {
await credentials.modify(providerId, () => Promise.resolve(credential));
}
const runtime = await ModelRuntime.create({ credentials, modelsPath: null, allowModelNetwork: false });
const auth = await AuthService.create({ runtime, ...(logger === undefined ? {} : { logger }) });
const changes: AuthChange[] = [];
auth.subscribe((change) => { changes.push(change); });
return { auth, authStorage, changes };
return { auth, runtime, credentials, changes };
}
async function createFileBackedAuthService(seed: Record<string, Credential>) {
const agentDir = await tempAgentDir();
const authPath = join(agentDir, "auth.json");
await writeFile(authPath, JSON.stringify(seed, null, 2));
const runtime = await createModelRuntimeForAgentDir(agentDir, false);
const auth = await AuthService.create({ runtime });
const changes: AuthChange[] = [];
auth.subscribe((change) => { changes.push(change); });
return { auth, runtime, authPath, changes };
}
function mockLoginPromptsBeforePersistence(
runtime: ModelRuntime,
credentials: InMemoryCredentialStore,
prompts: readonly AuthPrompt[],
) {
return vi.spyOn(runtime, "login").mockImplementation(async (providerId, _authType, interaction) => {
let key: string | undefined;
for (const prompt of prompts) key = await interaction.prompt(prompt);
if (key === undefined) throw new Error("Expected at least one login prompt");
const credential: Credential = { type: "api_key", key };
await credentials.modify(providerId, () => Promise.resolve(credential));
return credential;
});
}
async function tempAgentDir(): Promise<string> {
@@ -101,6 +508,28 @@ async function tempAgentDir(): Promise<string> {
return dir;
}
function deferred<T>() {
let resolveValue: (value: T) => void = () => undefined;
let rejectValue: (reason?: unknown) => void = () => undefined;
const promise = new Promise<T>((resolve, reject) => {
resolveValue = resolve;
rejectValue = reject;
});
return { promise, resolve: resolveValue, reject: rejectValue };
}
function radiusModelsConfig(name: string): string {
return JSON.stringify({
providers: {
"test-radius": {
name,
baseUrl: "https://radius.example.test/v1",
oauth: "radius",
},
},
});
}
class CapturingOAuthLoginFlowService extends OAuthLoginFlowService {
readonly startCalls: Parameters<OAuthLoginFlowService["start"]>[0][] = [];
disposed = false;
+106 -35
View File
@@ -1,5 +1,6 @@
import { join } from "node:path";
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import type { AuthInteraction } from "@earendil-works/pi-ai";
import type { AuthProvidersResponse, AuthType, OAuthFlowState } from "../../shared/apiTypes.js";
import { getLoginProviderOptions, getLogoutProviderOptions } from "./authProviderOptions.js";
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
@@ -8,28 +9,53 @@ export interface AuthChange {
removedProviderId?: string;
}
type AuthChangeListener = (change: AuthChange) => void;
type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
type AuthChangeListener = (change: AuthChange) => void | Promise<void>;
export interface AuthServiceDependencies {
agentDir?: string;
modelRegistry?: ModelRegistryInstance;
runtime?: ModelRuntime;
authFlows?: OAuthLoginFlowService;
logger?: AuthServiceLogger;
}
export function createModelRegistryForAgentDir(agentDir: string): ModelRegistryInstance {
const authStorage = AuthStorage.create(join(agentDir, "auth.json"));
return ModelRegistry.create(authStorage, join(agentDir, "models.json"));
/** Minimal structured-logging seam for non-fatal auth propagation failures. */
export interface AuthServiceLogger {
error(details: Record<string, unknown>, message: string): void;
}
interface AuthChangeContext {
operation: "login" | "logout";
providerId: string;
authType?: AuthType;
}
const noopLogger: AuthServiceLogger = { error() { /* no-op */ } };
export function createModelRuntimeForAgentDir(agentDir: string, allowModelNetwork?: boolean): Promise<ModelRuntime> {
return ModelRuntime.create({
authPath: join(agentDir, "auth.json"),
modelsPath: join(agentDir, "models.json"),
...(allowModelNetwork === undefined ? {} : { allowModelNetwork }),
});
}
export class AuthService {
readonly modelRegistry: ModelRegistryInstance;
readonly runtime: ModelRuntime;
private readonly authFlows: OAuthLoginFlowService;
private readonly logger: AuthServiceLogger;
private readonly listeners = new Set<AuthChangeListener>();
constructor(deps: AuthServiceDependencies = {}) {
this.modelRegistry = deps.modelRegistry ?? (deps.agentDir === undefined ? ModelRegistry.create(AuthStorage.create()) : createModelRegistryForAgentDir(deps.agentDir));
this.authFlows = deps.authFlows ?? new OAuthLoginFlowService();
private constructor(runtime: ModelRuntime, authFlows: OAuthLoginFlowService, logger: AuthServiceLogger) {
this.runtime = runtime;
this.authFlows = authFlows;
this.logger = logger;
}
static async create(deps: AuthServiceDependencies = {}): Promise<AuthService> {
const runtime = deps.runtime ?? (deps.agentDir === undefined ? await ModelRuntime.create({}) : await createModelRuntimeForAgentDir(deps.agentDir));
const logger = deps.logger ?? noopLogger;
const authFlows = deps.authFlows ?? new OAuthLoginFlowService({ logger });
return new AuthService(runtime, authFlows, logger);
}
subscribe(listener: AuthChangeListener): () => void {
@@ -44,34 +70,60 @@ export class AuthService {
this.listeners.clear();
}
authProviders(mode: "login" | "logout", authType?: AuthType): AuthProvidersResponse {
this.modelRegistry.refresh();
const providers = mode === "logout" ? getLogoutProviderOptions(this.modelRegistry) : getLoginProviderOptions(this.modelRegistry, authType);
async authProviders(mode: "login" | "logout", authType?: AuthType): Promise<AuthProvidersResponse> {
await this.runtime.reloadConfig();
const providers = mode === "logout" ? await getLogoutProviderOptions(this.runtime) : getLoginProviderOptions(this.runtime, authType);
return { providers };
}
saveApiKey(providerId: string, key: string): { accepted: true } {
async saveApiKey(providerId: string, key: string): Promise<{ accepted: true }> {
if (key.trim() === "") throw new Error("API key is required");
this.modelRegistry.authStorage.set(providerId, { type: "api_key", key });
this.refreshAuthState();
const provider = await this.requireApiKeyLoginProvider(providerId);
let promptAttempted = false;
const interaction: AuthInteraction = {
prompt: (prompt) => {
if (promptAttempted) {
throw new Error(`${provider.name} requires interactive setup; use Pi's generic /login flow`);
}
promptAttempted = true;
if (prompt.signal?.aborted === true) throw new Error("Login cancelled");
if (prompt.type !== "secret") {
throw new Error(`${provider.name} requires interactive setup; use Pi's generic /login flow`);
}
return Promise.resolve(key);
},
notify: () => undefined,
};
await this.runtime.login(providerId, "api_key", interaction);
await this.emit({}, { operation: "login", providerId, authType: "api_key" });
return { accepted: true };
}
logoutProvider(providerId: string): { accepted: true } {
this.modelRegistry.authStorage.logout(providerId);
this.refreshAuthState({ removedProviderId: providerId });
async logoutProvider(providerId: string): Promise<{ accepted: true }> {
await this.runtime.logout(providerId);
await this.emit({ removedProviderId: providerId }, { operation: "logout", providerId });
return { accepted: true };
}
startOAuthLogin(providerId: string): OAuthFlowState {
const provider = this.requireOAuthLoginProvider(providerId);
async startApiKeyLogin(providerId: string): Promise<OAuthFlowState> {
const provider = await this.requireApiKeyLoginProvider(providerId);
return this.authFlows.start({
providerId,
providerName: provider.name,
authStorage: this.modelRegistry.authStorage,
onComplete: () => {
this.refreshAuthState();
},
runtime: this.runtime,
authType: "api_key",
onComplete: () => this.emit({}, { operation: "login", providerId, authType: "api_key" }),
});
}
async startOAuthLogin(providerId: string): Promise<OAuthFlowState> {
const provider = await this.requireOAuthLoginProvider(providerId);
return this.authFlows.start({
providerId,
providerName: provider.name,
runtime: this.runtime,
authType: "oauth",
onComplete: () => this.emit({}, { operation: "login", providerId, authType: "oauth" }),
});
}
@@ -87,19 +139,38 @@ export class AuthService {
return this.authFlows.cancel(flowId);
}
private refreshAuthState(change: AuthChange = {}): void {
this.modelRegistry.authStorage.reload();
this.modelRegistry.refresh();
this.emit(change);
private async emit(change: AuthChange, context: AuthChangeContext): Promise<void> {
const results = await Promise.allSettled([...this.listeners].map(async (listener) => listener(change)));
for (const result of results) {
if (result.status === "rejected") {
this.logErrorNoThrow({ err: result.reason, ...context }, "auth-change listener failed");
}
}
}
private emit(change: AuthChange): void {
for (const listener of this.listeners) listener(change);
private logErrorNoThrow(details: Record<string, unknown>, message: string): void {
try {
this.logger.error(details, message);
} catch {
// A diagnostic failure cannot turn an already-committed auth mutation into an API failure.
}
}
private requireOAuthLoginProvider(providerId: string) {
this.modelRegistry.refresh();
const provider = getLoginProviderOptions(this.modelRegistry, "oauth").find((option) => option.id === providerId);
private async requireApiKeyLoginProvider(providerId: string) {
await this.runtime.reloadConfig();
const provider = getLoginProviderOptions(this.runtime, "api_key").find((option) => option.id === providerId);
if (provider !== undefined) return provider;
const knownProvider = this.runtime.getProviders().find((option) => option.id === providerId);
if (knownProvider !== undefined) {
throw new Error(`${knownProvider.name} does not support interactive API-key setup`);
}
throw new Error(`API key provider not found: ${providerId}`);
}
private async requireOAuthLoginProvider(providerId: string) {
await this.runtime.reloadConfig();
const provider = getLoginProviderOptions(this.runtime, "oauth").find((option) => option.id === providerId);
if (provider === undefined) throw new Error(`OAuth provider not found: ${providerId}`);
return provider;
}
+268 -36
View File
@@ -1,9 +1,9 @@
import type { OAuthLoginCallbacks } from "@earendil-works/pi-ai";
import type { AuthStorage } from "@earendil-works/pi-coding-agent";
import type { AuthInteraction, AuthType } from "@earendil-works/pi-ai";
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { afterEach, describe, expect, it, vi } from "vitest";
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
type LoginHandler = (providerId: string, callbacks: OAuthLoginCallbacks) => Promise<void>;
type LoginHandler = (providerId: string, interaction: AuthInteraction) => Promise<void>;
afterEach(() => {
vi.useRealTimers();
@@ -17,19 +17,19 @@ describe("OAuthLoginFlowService", () => {
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
callbacks.onAuth({ url: "https://example.test/auth", instructions: "Open it" });
callbacks.onProgress?.("Waiting for code");
promptValue = await callbacks.onPrompt({ message: "Paste code", placeholder: "code" });
callbacks.onProgress?.(`Got ${promptValue}`);
runtime: fakeRuntime(async (_providerId, interaction) => {
interaction.notify({ type: "auth_url", url: "https://example.test/auth", instructions: "Open it" });
interaction.notify({ type: "progress", message: "Waiting for code" });
promptValue = await interaction.prompt({ type: "text", message: "Paste code", placeholder: "code" });
interaction.notify({ type: "progress", message: `Got ${promptValue}` });
}),
onComplete,
});
const prompt = state.prompt;
if (prompt === undefined) throw new Error("Expected prompt");
expect(state).toMatchObject({ auth: { url: "https://example.test/auth" }, progress: ["Waiting for code"] });
expect(prompt).toMatchObject({ message: "Paste code", placeholder: "code", kind: "prompt" });
expect(state).toMatchObject({ auth: { url: "https://example.test/auth", instructions: "Open it" }, progress: ["Waiting for code"] });
expect(prompt).toMatchObject({ message: "Paste code", placeholder: "code", kind: "prompt", promptType: "text", allowEmpty: true });
const afterRespond = service.respond(state.flowId, prompt.requestId, "abc123");
expect(afterRespond.prompt).toBeUndefined();
@@ -41,23 +41,191 @@ describe("OAuthLoginFlowService", () => {
service.dispose();
});
it("runs API-key login through the same AuthInteraction transport", async () => {
const authTypes: AuthType[] = [];
let key: string | undefined;
const service = new OAuthLoginFlowService();
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
runtime: fakeRuntime(async (_providerId, interaction) => {
key = await interaction.prompt({ type: "secret", message: "Enter API key" });
}, authTypes),
authType: "api_key",
});
const prompt = state.prompt;
if (prompt === undefined) throw new Error("Expected API-key prompt");
service.respond(state.flowId, prompt.requestId, "sk-test");
await flushAsyncLogin();
expect(authTypes).toEqual(["api_key"]);
expect(key).toBe("sk-test");
expect(service.get(state.flowId).status).toBe("complete");
service.dispose();
});
it("awaits async completion propagation before marking the flow complete", async () => {
const completion = deferred<undefined>();
const service = new OAuthLoginFlowService();
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
runtime: fakeRuntime(() => Promise.resolve()),
onComplete: () => completion.promise,
});
await flushAsyncLogin();
expect(service.get(state.flowId).status).toBe("running");
completion.resolve(undefined);
await flushAsyncLogin();
expect(service.get(state.flowId).status).toBe("complete");
service.dispose();
});
it("keeps a committed login complete when its completion callback and logger throw", async () => {
const completionFailure = new Error("completion propagation failed");
const loggingFailure = new Error("OAuth logger failed");
const error = vi.fn(() => { throw loggingFailure; });
const onComplete = vi.fn(() => { throw completionFailure; });
const service = new OAuthLoginFlowService({ logger: { error } });
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
runtime: fakeRuntime(() => Promise.resolve()),
onComplete,
});
await vi.waitFor(() => { expect(service.get(state.flowId).status).toBe("complete"); });
expect(service.get(state.flowId)).toMatchObject({ status: "complete", progress: ["Login complete"] });
expect(onComplete).toHaveBeenCalledOnce();
expect(error).toHaveBeenCalledWith(
{ err: completionFailure, flowId: state.flowId, providerId: "test-provider" },
"login completion callback failed",
);
service.dispose();
});
it("allows blank text responses for providers that use blank as a default", async () => {
let domain: string | undefined;
const service = new OAuthLoginFlowService();
const state = service.start({
providerId: "github-copilot",
providerName: "GitHub Copilot",
runtime: fakeRuntime(async (_providerId, interaction) => {
domain = await interaction.prompt({
type: "text",
message: "GitHub Enterprise URL/domain (blank for github.com)",
});
}),
});
const prompt = state.prompt;
if (prompt === undefined) throw new Error("Expected text prompt");
expect(prompt).toMatchObject({ kind: "prompt", promptType: "text", allowEmpty: true });
service.respond(state.flowId, prompt.requestId, "");
await flushAsyncLogin();
expect(domain).toBe("");
expect(service.get(state.flowId).status).toBe("complete");
service.dispose();
});
it("preserves secret prompt semantics behind the legacy prompt kind", () => {
const service = new OAuthLoginFlowService();
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
runtime: fakeRuntime(async (_providerId, interaction) => {
await interaction.prompt({ type: "secret", message: "Enter secret", placeholder: "token" });
}),
});
const prompt = state.prompt;
if (prompt === undefined) throw new Error("Expected secret prompt");
expect(prompt).toMatchObject({
kind: "prompt",
promptType: "secret",
message: "Enter secret",
placeholder: "token",
});
expect(prompt).not.toHaveProperty("allowEmpty");
expect(() => { service.respond(state.flowId, prompt.requestId, ""); }).toThrow("A value is required");
service.dispose();
});
it("preserves info-event links without replacing the authorization URL", () => {
const service = new OAuthLoginFlowService();
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
runtime: fakeRuntime(async (_providerId, interaction) => {
interaction.notify({ type: "auth_url", url: "https://example.test/login" });
interaction.notify({
type: "info",
message: "Review the provider setup guide",
links: [{ url: "https://example.test/docs", label: "Setup guide" }],
});
await interaction.prompt({ type: "text", message: "Continue" });
}),
});
expect(state).toMatchObject({
auth: { url: "https://example.test/login" },
progress: ["Review the provider setup guide"],
info: [{ message: "Review the provider setup guide", links: [{ url: "https://example.test/docs", label: "Setup guide" }] }],
});
service.dispose();
});
it("surfaces device-code events through the auth field", () => {
const service = new OAuthLoginFlowService();
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
runtime: fakeRuntime(async (_providerId, interaction) => {
interaction.notify({
type: "device_code",
userCode: "WXYZ-1234",
verificationUri: "https://example.test/device",
intervalSeconds: 5,
expiresInSeconds: 900,
});
await interaction.prompt({ type: "text", message: "Waiting" });
}),
});
expect(service.get(state.flowId)).toMatchObject({
auth: {
url: "https://example.test/device",
instructions: "Enter code: WXYZ-1234",
deviceCode: { userCode: "WXYZ-1234", intervalSeconds: 5, expiresInSeconds: 900 },
},
});
service.dispose();
});
it("round-trips select responses", async () => {
let selectedValue: string | undefined;
const service = new OAuthLoginFlowService();
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
selectedValue = await callbacks.onSelect({
runtime: fakeRuntime(async (_providerId, interaction) => {
selectedValue = await interaction.prompt({
type: "select",
message: "Choose account",
options: [{ id: "work", label: "Work" }, { id: "personal", label: "Personal" }],
options: [{ id: "work", label: "Work", description: "Company account" }, { id: "personal", label: "Personal" }],
});
}),
});
const select = state.select;
if (select === undefined) throw new Error("Expected select prompt");
expect(select).toMatchObject({ message: "Choose account", options: [{ value: "work", label: "Work" }, { value: "personal", label: "Personal" }] });
expect(select).toMatchObject({ message: "Choose account", options: [{ value: "work", label: "Work", description: "Company account" }, { value: "personal", label: "Personal" }] });
service.respond(state.flowId, select.requestId, "personal");
await flushAsyncLogin();
@@ -67,40 +235,97 @@ describe("OAuthLoginFlowService", () => {
service.dispose();
});
it("uses a manual-code prompt for callback-server flows", async () => {
let manualValue: string | undefined;
it("rejects responses outside the pending select options", () => {
const service = new OAuthLoginFlowService();
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
const manualCodeInput = callbacks.onManualCodeInput;
if (manualCodeInput === undefined) throw new Error("Expected manual-code callback");
manualValue = await manualCodeInput();
runtime: fakeRuntime(async (_providerId, interaction) => {
await interaction.prompt({
type: "select",
message: "Choose account",
options: [{ id: "work", label: "Work" }],
});
}),
});
const select = state.select;
if (select === undefined) throw new Error("Expected select prompt");
expect(() => { service.respond(state.flowId, select.requestId, "personal"); }).toThrow("Invalid login selection");
expect(service.get(state.flowId).select).toEqual(select);
service.dispose();
});
it("uses a manual-code prompt for callback-server flows and cleans up its abort listener", async () => {
let manualValue: string | undefined;
const service = new OAuthLoginFlowService();
const controller = new AbortController();
const removeAbortListener = vi.spyOn(controller.signal, "removeEventListener");
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
runtime: fakeRuntime(async (_providerId, interaction) => {
manualValue = await interaction.prompt({
type: "manual_code",
message: "Paste the callback URL or authorization code",
signal: controller.signal,
});
}),
});
const prompt = state.prompt;
if (prompt === undefined) throw new Error("Expected manual prompt");
expect(prompt).toMatchObject({ kind: "manual", message: "Paste the callback URL or authorization code" });
expect(prompt).toMatchObject({ kind: "manual", promptType: "manual_code", message: "Paste the callback URL or authorization code" });
service.respond(state.flowId, prompt.requestId, "https://localhost/callback?code=abc");
await flushAsyncLogin();
expect(manualValue).toBe("https://localhost/callback?code=abc");
expect(removeAbortListener).toHaveBeenCalledWith("abort", expect.any(Function));
expect(service.get(state.flowId).status).toBe("complete");
service.dispose();
});
it("rejects a pending prompt when its own signal aborts without ending the flow", async () => {
const promptRejected = deferred<Error>();
const service = new OAuthLoginFlowService();
const controller = new AbortController();
const removeAbortListener = vi.spyOn(controller.signal, "removeEventListener");
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
runtime: fakeRuntime(async (_providerId, interaction) => {
try {
await interaction.prompt({ type: "manual_code", message: "Paste code", signal: controller.signal });
} catch (error) {
promptRejected.resolve(toError(error));
}
// The flow keeps running (e.g. the callback server resolves it) until we
// resolve the follow-up prompt below.
await interaction.prompt({ type: "text", message: "Waiting for callback" });
}),
});
expect(state.prompt).toMatchObject({ kind: "manual" });
controller.abort();
await expect(promptRejected.promise).resolves.toMatchObject({ message: "Prompt cancelled" });
expect(removeAbortListener).toHaveBeenCalledWith("abort", expect.any(Function));
const afterAbort = service.get(state.flowId);
expect(afterAbort.status).toBe("running");
expect(afterAbort.prompt).toMatchObject({ kind: "prompt", message: "Waiting for callback" });
service.dispose();
});
it("rejects pending prompts when cancelled", async () => {
const promptRejected = deferred<Error>();
const service = new OAuthLoginFlowService();
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
runtime: fakeRuntime(async (_providerId, interaction) => {
try {
await callbacks.onPrompt({ message: "Paste code" });
await interaction.prompt({ type: "text", message: "Paste code" });
} catch (error) {
promptRejected.resolve(toError(error));
throw error;
@@ -122,9 +347,9 @@ describe("OAuthLoginFlowService", () => {
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
runtime: fakeRuntime(async (_providerId, interaction) => {
try {
await callbacks.onPrompt({ message: "Paste code" });
await interaction.prompt({ type: "text", message: "Paste code" });
} catch (error) {
promptRejected.resolve(toError(error));
throw error;
@@ -137,7 +362,7 @@ describe("OAuthLoginFlowService", () => {
service.dispose();
await expect(promptRejected.promise).resolves.toMatchObject({ message: "Login cancelled" });
expect(() => { service.get(state.flowId); }).toThrow("OAuth login flow not found");
expect(() => { service.get(state.flowId); }).toThrow("Login flow not found");
});
it("rejects stale or duplicate responses", () => {
@@ -145,8 +370,8 @@ describe("OAuthLoginFlowService", () => {
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
await callbacks.onPrompt({ message: "Paste code" });
runtime: fakeRuntime(async (_providerId, interaction) => {
await interaction.prompt({ type: "text", message: "Paste code" });
}),
});
@@ -154,7 +379,7 @@ describe("OAuthLoginFlowService", () => {
if (prompt === undefined) throw new Error("Expected prompt");
service.respond(state.flowId, prompt.requestId, "abc123");
expect(() => { service.respond(state.flowId, prompt.requestId, "abc123"); }).toThrow("OAuth login request expired");
expect(() => { service.respond(state.flowId, prompt.requestId, "abc123"); }).toThrow("Login request expired");
service.dispose();
});
@@ -165,9 +390,9 @@ describe("OAuthLoginFlowService", () => {
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
runtime: fakeRuntime(async (_providerId, interaction) => {
try {
await callbacks.onPrompt({ message: "Paste code" });
await interaction.prompt({ type: "text", message: "Paste code" });
} catch (error) {
promptRejected.resolve(toError(error));
throw error;
@@ -177,18 +402,25 @@ describe("OAuthLoginFlowService", () => {
await vi.advanceTimersByTimeAsync(1000);
expect(service.get(state.flowId)).toMatchObject({ status: "error", error: "OAuth login flow expired" });
await expect(promptRejected.promise).resolves.toMatchObject({ message: "OAuth login flow expired" });
expect(service.get(state.flowId)).toMatchObject({ status: "error", error: "Login flow expired" });
await expect(promptRejected.promise).resolves.toMatchObject({ message: "Login flow expired" });
await vi.advanceTimersByTimeAsync(1000);
expect(() => { service.get(state.flowId); }).toThrow("OAuth login flow not found");
expect(() => { service.get(state.flowId); }).toThrow("Login flow not found");
service.dispose();
});
});
function fakeAuthStorage(login: LoginHandler): Pick<AuthStorage, "login"> {
return { login };
function fakeRuntime(login: LoginHandler, authTypes?: AuthType[]): Pick<ModelRuntime, "login"> {
return {
login: (providerId, type, interaction) => {
authTypes?.push(type);
return login(providerId, interaction).then(() => type === "api_key"
? { type: "api_key", key: "test" }
: { type: "oauth", refresh: "r", access: "a", expires: 0 });
},
};
}
async function flushAsyncLogin(): Promise<void> {
+198 -57
View File
@@ -1,16 +1,21 @@
import crypto from "node:crypto";
import type { OAuthLoginCallbacks, OAuthSelectPrompt, OAuthPrompt } from "@earendil-works/pi-ai";
import type { AuthStorage } from "@earendil-works/pi-coding-agent";
import type { AuthEvent, AuthInteraction, AuthPrompt, AuthType } from "@earendil-works/pi-ai";
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
import type { CommandOption, OAuthFlowState } from "../../shared/apiTypes.js";
type OAuthLoginStorage = Pick<AuthStorage, "login">;
/** The single runtime capability this service drives — narrowed for testable DI. */
type OAuthLoginRuntime = Pick<ModelRuntime, "login">;
type TimerHandle = ReturnType<typeof setTimeout>;
type SelectPrompt = Extract<AuthPrompt, { type: "select" }>;
type ValuePrompt = Exclude<AuthPrompt, { type: "select" }>;
interface PendingOAuthRequest {
requestId: string;
allowEmpty: boolean;
resolve: (value: string | undefined) => void;
resolve: (value: string) => void;
reject: (error: Error) => void;
allowedValues?: ReadonlySet<string>;
cleanup?: () => void;
}
interface OAuthFlowRecord {
@@ -22,32 +27,46 @@ interface OAuthFlowRecord {
cleanupTimer?: TimerHandle;
}
export interface OAuthLoginFlowLogger {
error(details: Record<string, unknown>, message: string): void;
}
export interface OAuthLoginFlowServiceOptions {
terminalTtlMs?: number;
runningTtlMs?: number;
now?: () => number;
logger?: OAuthLoginFlowLogger;
}
const DEFAULT_TERMINAL_TTL_MS = 5 * 60 * 1000;
const DEFAULT_RUNNING_TTL_MS = 30 * 60 * 1000;
const noopLogger: OAuthLoginFlowLogger = { error() { /* no-op */ } };
/**
* AuthInteraction transport shared by OAuth and provider-driven API-key login.
* The historical class and wire names remain for rolling browser/sessiond compatibility.
*/
export class OAuthLoginFlowService {
private readonly flows = new Map<string, OAuthFlowRecord>();
private readonly terminalTtlMs: number;
private readonly runningTtlMs: number;
private readonly now: () => number;
private readonly logger: OAuthLoginFlowLogger;
constructor(options: OAuthLoginFlowServiceOptions = {}) {
this.terminalTtlMs = options.terminalTtlMs ?? DEFAULT_TERMINAL_TTL_MS;
this.runningTtlMs = options.runningTtlMs ?? DEFAULT_RUNNING_TTL_MS;
this.now = options.now ?? (() => Date.now());
this.logger = options.logger ?? noopLogger;
}
start(options: {
providerId: string;
providerName: string;
authStorage: OAuthLoginStorage;
onComplete?: () => void;
runtime: OAuthLoginRuntime;
/** Defaults to OAuth so established callers retain their existing behavior. */
authType?: AuthType;
onComplete?: () => void | Promise<void>;
}): OAuthFlowState {
const flowId = crypto.randomUUID();
const abort = new AbortController();
@@ -66,58 +85,43 @@ export class OAuthLoginFlowService {
this.flows.set(flowId, record);
this.scheduleRunningExpiry(record);
const callbacks: OAuthLoginCallbacks = {
// Adapt the pi-ai AuthInteraction contract onto the web-UI flow state:
// `prompt()` returns the entered/selected string; `notify()` surfaces
// out-of-band login events (auth URL, device code, progress).
const interaction: AuthInteraction = {
signal: abort.signal,
onAuth: (info) => {
if (!this.isCurrentRunning(record)) return;
this.updateState(record, { ...record.state, auth: info });
},
// Device-code flows have no redirect URL; reuse the auth field so the web UI
// shows the verification link and user code without a dedicated API shape.
onDeviceCode: (info) => {
if (!this.isCurrentRunning(record)) return;
this.updateState(record, { ...record.state, auth: { url: info.verificationUri, instructions: `Enter code: ${info.userCode}` } });
},
onPrompt: (prompt) => this.waitForPrompt(record, prompt, "prompt"),
onManualCodeInput: () => this.waitForPrompt(record, { message: "Paste the callback URL or authorization code", allowEmpty: false }, "manual"),
onSelect: (prompt) => this.waitForSelect(record, prompt),
onProgress: (message) => {
if (!this.isCurrentRunning(record)) return;
this.updateState(record, { ...record.state, progress: [...record.state.progress, message] });
},
prompt: (prompt) => this.handlePrompt(record, prompt),
notify: (event) => { this.handleEvent(record, event); },
};
void options.authStorage.login(options.providerId, callbacks)
.then(() => {
if (!this.isCurrentRunning(record)) return;
record.pending = undefined;
this.markTerminal(record, { ...withoutInteraction(record.state), status: "complete", progress: [...record.state.progress, "Login complete"] });
options.onComplete?.();
})
.catch((error: unknown) => {
if (this.flows.get(record.flowId) !== record) return;
record.pending = undefined;
void options.runtime.login(options.providerId, options.authType ?? "oauth", interaction).then(
() => this.reconcileCommittedLogin(record, options.onComplete),
(error: unknown) => {
if (!this.isCurrent(record)) return;
this.clearPending(record);
if (record.state.status !== "running") return;
this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: error instanceof Error ? error.message : String(error) });
});
},
);
return this.get(flowId);
}
get(flowId: string): OAuthFlowState {
const record = this.flows.get(flowId);
if (record === undefined) throw new Error("OAuth login flow not found");
if (record === undefined) throw new Error("Login flow not found");
return cloneState(record.state);
}
respond(flowId: string, requestId: string, value: string): OAuthFlowState {
const record = this.flows.get(flowId);
if (record === undefined) throw new Error("OAuth login flow not found");
if (record === undefined) throw new Error("Login flow not found");
if (record.state.status !== "running") return cloneState(record.state);
const pending = record.pending;
if (pending?.requestId !== requestId) throw new Error("OAuth login request expired");
if (pending?.requestId !== requestId) throw new Error("Login request expired");
if (!pending.allowEmpty && value.trim() === "") throw new Error("A value is required");
record.pending = undefined;
if (pending.allowedValues !== undefined && !pending.allowedValues.has(value)) throw new Error("Invalid login selection");
this.clearPending(record);
this.updateState(record, withoutInteraction(record.state));
pending.resolve(value);
return cloneState(record.state);
@@ -125,11 +129,10 @@ export class OAuthLoginFlowService {
cancel(flowId: string): OAuthFlowState {
const record = this.flows.get(flowId);
if (record === undefined) throw new Error("OAuth login flow not found");
if (record === undefined) throw new Error("Login flow not found");
if (record.state.status === "running") {
record.abort.abort();
const pending = record.pending;
record.pending = undefined;
const pending = this.clearPending(record);
this.markTerminal(record, { ...withoutInteraction(record.state), status: "cancelled", error: "Login cancelled" });
pending?.reject(new Error("Login cancelled"));
}
@@ -140,51 +143,179 @@ export class OAuthLoginFlowService {
for (const record of this.flows.values()) {
this.clearTimer(record);
record.abort.abort();
const pending = record.pending;
record.pending = undefined;
const pending = this.clearPending(record);
pending?.reject(new Error("Login cancelled"));
}
this.flows.clear();
}
private waitForPrompt(record: OAuthFlowRecord, prompt: OAuthPrompt, kind: "prompt" | "manual"): Promise<string> {
private handlePrompt(record: OAuthFlowRecord, prompt: AuthPrompt): Promise<string> {
if (prompt.type === "select") return this.waitForSelect(record, prompt);
return this.waitForPrompt(record, prompt);
}
private handleEvent(record: OAuthFlowRecord, event: AuthEvent): void {
if (!this.isCurrentRunning(record)) return;
switch (event.type) {
case "auth_url":
this.updateState(record, { ...record.state, auth: { url: event.url, ...(event.instructions === undefined ? {} : { instructions: event.instructions }) } });
return;
// Keep the legacy auth URL/instructions while adding structured metadata
// that newer browsers can use during rolling sessiond upgrades.
case "device_code":
this.updateState(record, {
...record.state,
auth: {
url: event.verificationUri,
instructions: `Enter code: ${event.userCode}`,
deviceCode: {
userCode: event.userCode,
...(event.intervalSeconds === undefined ? {} : { intervalSeconds: event.intervalSeconds }),
...(event.expiresInSeconds === undefined ? {} : { expiresInSeconds: event.expiresInSeconds }),
},
},
});
return;
case "info":
this.updateState(record, {
...record.state,
progress: [...record.state.progress, event.message],
info: [
...(record.state.info ?? []),
{
message: event.message,
...(event.links === undefined ? {} : {
links: event.links.map((link) => ({
url: link.url,
...(link.label === undefined ? {} : { label: link.label }),
})),
}),
},
],
});
return;
case "progress":
this.updateState(record, { ...record.state, progress: [...record.state.progress, event.message] });
return;
}
}
private waitForPrompt(record: OAuthFlowRecord, prompt: ValuePrompt): Promise<string> {
return new Promise((resolve, reject) => {
if (!this.isCurrentRunning(record)) {
reject(new Error("Login cancelled"));
return;
}
const requestId = crypto.randomUUID();
record.pending = { requestId, allowEmpty: prompt.allowEmpty === true, resolve: (value) => { resolve(value ?? ""); }, reject };
const pending: PendingOAuthRequest = {
requestId,
allowEmpty: prompt.type === "text",
resolve,
reject,
};
record.pending = pending;
if (!this.bindPromptSignal(record, pending, prompt.signal)) return;
const base = withoutInteraction(record.state);
this.updateState(record, {
...base,
prompt: {
requestId,
message: prompt.message,
kind,
kind: prompt.type === "manual_code" ? "manual" : "prompt",
promptType: prompt.type,
...(prompt.type === "text" ? { allowEmpty: true } : {}),
...(prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder }),
...(prompt.allowEmpty === true ? { allowEmpty: true } : {}),
},
});
});
}
private waitForSelect(record: OAuthFlowRecord, prompt: OAuthSelectPrompt): Promise<string | undefined> {
private waitForSelect(record: OAuthFlowRecord, prompt: SelectPrompt): Promise<string> {
return new Promise((resolve, reject) => {
if (!this.isCurrentRunning(record)) {
reject(new Error("Login cancelled"));
return;
}
const requestId = crypto.randomUUID();
const options: CommandOption[] = prompt.options.map((option) => ({ value: option.id, label: option.label }));
record.pending = { requestId, allowEmpty: true, resolve, reject };
const options: CommandOption[] = prompt.options.map((option) => ({
value: option.id,
label: option.label,
...(option.description === undefined ? {} : { description: option.description }),
}));
const pending: PendingOAuthRequest = {
requestId,
allowEmpty: false,
resolve,
reject,
allowedValues: new Set(options.map((option) => option.value)),
};
record.pending = pending;
if (!this.bindPromptSignal(record, pending, prompt.signal)) return;
const base = withoutInteraction(record.state);
this.updateState(record, { ...base, select: { requestId, message: prompt.message, options } });
});
}
// A prompt may carry its own AbortSignal (e.g. a manual_code prompt raced
// against a callback server). When it fires, drop just that pending request
// and clear the interaction from state — the overall login keeps running.
private bindPromptSignal(record: OAuthFlowRecord, pending: PendingOAuthRequest, signal?: AbortSignal): boolean {
if (signal === undefined) return true;
const onAbort = () => {
if (record.pending !== pending) return;
this.clearPending(record);
if (this.isCurrentRunning(record)) this.updateState(record, withoutInteraction(record.state));
pending.reject(new Error("Prompt cancelled"));
};
pending.cleanup = () => { signal.removeEventListener("abort", onAbort); };
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) {
onAbort();
return false;
}
return true;
}
private clearPending(record: OAuthFlowRecord): PendingOAuthRequest | undefined {
const pending = record.pending;
record.pending = undefined;
pending?.cleanup?.();
return pending;
}
// ModelRuntime persists the credential before its post-login refresh. If a
// cancellation lands during that refresh, the resolved login is committed
// truth and must supersede the transient cancelled state.
private async reconcileCommittedLogin(record: OAuthFlowRecord, onComplete?: () => void | Promise<void>): Promise<void> {
if (this.isCurrent(record)) this.clearPending(record);
try {
await onComplete?.();
} catch (error) {
this.logErrorNoThrow(
{ err: error, flowId: record.flowId, providerId: record.state.providerId },
"login completion callback failed",
);
}
if (!this.isCurrent(record)) return;
const completed = withoutInteraction(record.state);
delete completed.error;
this.markTerminal(record, { ...completed, status: "complete", progress: [...record.state.progress, "Login complete"] });
}
private isCurrent(record: OAuthFlowRecord): boolean {
return this.flows.get(record.flowId) === record;
}
private isCurrentRunning(record: OAuthFlowRecord): boolean {
return this.flows.get(record.flowId) === record && record.state.status === "running";
return this.isCurrent(record) && record.state.status === "running";
}
private logErrorNoThrow(details: Record<string, unknown>, message: string): void {
try {
this.logger.error(details, message);
} catch {
// Logging is post-commit diagnostics and must never change auth truth.
}
}
private updateState(record: OAuthFlowRecord, state: OAuthFlowState): void {
@@ -226,10 +357,9 @@ export class OAuthLoginFlowService {
private expireRunningFlow(record: OAuthFlowRecord): void {
if (!this.isCurrentRunning(record)) return;
record.abort.abort();
const pending = record.pending;
record.pending = undefined;
this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: "OAuth login flow expired" });
pending?.reject(new Error("OAuth login flow expired"));
const pending = this.clearPending(record);
this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: "Login flow expired" });
pending?.reject(new Error("Login flow expired"));
}
private setTimer(record: OAuthFlowRecord, delayMs: number, callback: () => void): void {
@@ -256,9 +386,20 @@ function cloneState(state: OAuthFlowState): OAuthFlowState {
return {
...state,
progress: [...state.progress],
...(state.auth === undefined ? {} : { auth: { ...state.auth } }),
...(state.auth === undefined ? {} : {
auth: {
...state.auth,
...(state.auth.deviceCode === undefined ? {} : { deviceCode: { ...state.auth.deviceCode } }),
},
}),
...(state.prompt === undefined ? {} : { prompt: { ...state.prompt } }),
...(state.select === undefined ? {} : { select: { ...state.select, options: state.select.options.map((option) => ({ ...option })) } }),
...(state.info === undefined ? {} : {
info: state.info.map((item) => ({
...item,
...(item.links === undefined ? {} : { links: item.links.map((link) => ({ ...link })) }),
})),
}),
};
}
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { PiSessionService } from "./piSessionService.js";
import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef } from "./piSessionService.testSupport.js";
import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModelRuntime } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
@@ -15,6 +15,7 @@ describe("PiSessionService archive and cleanup", () => {
const fake = fakeRuntime("root", { sessionFile: root.path });
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived-child", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: archivedChild.path, archivePath: "/archive/archived-child.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 1, firstMessage: "archived", parentSessionPath: root.path }]),
@@ -49,6 +50,7 @@ describe("PiSessionService archive and cleanup", () => {
const deletedSessionIds: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
archiveStore: {
list: () => Promise.resolve([]),
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
@@ -83,6 +85,7 @@ describe("PiSessionService archive and cleanup", () => {
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
archiveStore: {
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
@@ -118,6 +121,7 @@ describe("PiSessionService archive and cleanup", () => {
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: () => {
createCalls += 1;
return Promise.resolve(busy.runtime);
@@ -159,6 +163,7 @@ describe("PiSessionService archive and cleanup", () => {
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(busy.runtime),
archiveStore: {
list: () => Promise.resolve([busyRecord, idleRecord]),
@@ -196,6 +201,7 @@ describe("PiSessionService archive and cleanup", () => {
const listCalls: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
archiveStore: {
list: () => Promise.resolve([
{ sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" },
@@ -239,6 +245,7 @@ describe("PiSessionService archive and cleanup", () => {
const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" };
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
now: () => new Date("2026-06-25T00:00:00.000Z"),
archiveStore: {
list: () => Promise.resolve([archived, otherArchived]),
@@ -292,6 +299,7 @@ describe("PiSessionService archive and cleanup", () => {
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
now: () => new Date("2026-06-25T00:00:00.000Z"),
archiveStore: {
list: () => Promise.resolve([
@@ -334,6 +342,7 @@ describe("PiSessionService archive and cleanup", () => {
const archivedInputs: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
now: () => new Date("2026-06-25T00:00:00.000Z"),
createAgentRuntime: runtimeCreator(fake.runtime),
archiveStore: {
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { PiSessionService, type PiAgentSession, type PiSessionRuntime } from "./piSessionService.js";
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, type RuntimeCreator } from "./piSessionService.testSupport.js";
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
@@ -31,6 +31,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
};
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -60,6 +61,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
try {
service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -87,6 +89,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const open = vi.fn(() => fakeSessionManager());
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: {
create: () => fakeSessionManager(),
@@ -136,6 +139,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const open = vi.spyOn(gateway, "open");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
archiveStore: emptyArchiveStore(),
createAgentRuntime,
sessionManager: gateway,
@@ -190,6 +194,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
archiveStore: emptyArchiveStore(),
createAgentRuntime,
sessionManager: sessionGateway([sessionRecord(sessionId)]),
@@ -230,6 +235,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const fake = fakeRuntime(sessionId);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
archiveStore: emptyArchiveStore(),
createAgentRuntime: () => {
createStarted.resolve();
@@ -264,6 +270,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
fake.runtime.setRebindSession = (callback) => { rebindSession = callback; };
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -291,6 +298,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
});
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -326,6 +334,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
});
service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("idle-session")]),
heartbeatIntervalMs: 1_000,
@@ -362,6 +371,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
});
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("completion-session")]),
heartbeatIntervalMs: 60_000,
@@ -381,6 +391,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
it("uses injected archive and session-manager gateways for listing", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]),
get: () => Promise.resolve(undefined),
@@ -411,6 +422,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
it("lists archived records that have been moved out of the active session directory", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]),
get: () => Promise.resolve(undefined),
@@ -442,6 +454,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const fake = fakeRuntime("runtime-reload-session");
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]),
heartbeatIntervalMs: 60_000,
@@ -475,6 +488,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: sessionGateway([sessionRecord("reload-session")]),
heartbeatIntervalMs: 60_000,
@@ -499,6 +513,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const fake = fakeRuntime("busy-session", { isStreaming: true });
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("busy-session")]),
heartbeatIntervalMs: 60_000,
@@ -514,6 +529,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
it("refuses to reload an archived session", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
archiveStore: {
list: () => Promise.resolve([]),
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
@@ -536,6 +552,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const reconciliations: { cwd: string; sessionIds: string[] }[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]),
get: () => Promise.resolve(undefined),
@@ -573,6 +590,7 @@ describe("PiSessionService.streamSnapshot", () => {
const fake = fakeRuntime("snap-idle");
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -601,6 +619,7 @@ describe("PiSessionService.streamSnapshot", () => {
const fake = fakeRuntime("snap-live", { state: { streamingMessage } });
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -1,9 +1,12 @@
import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createAssistantMessageEventStream, InMemoryCredentialStore, type AssistantMessage } from "@earendil-works/pi-ai";
import type { StreamFn } from "@earendil-works/pi-agent-core";
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { PiSessionService } from "./piSessionService.js";
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
import { CapturingSessionEventHub, createTestModelRuntime, fakeRuntime, runtimeCreator, seedCredential, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
@@ -12,6 +15,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const fake = fakeRuntime("prompt-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
heartbeatIntervalMs: 60_000,
@@ -30,6 +34,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const hub = new CapturingSessionEventHub();
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("echo-session")]),
heartbeatIntervalMs: 60_000,
@@ -60,6 +65,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
heartbeatIntervalMs: 60_000,
@@ -96,6 +102,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const fake = fakeRuntime("name-session", { model, agent: { streamFn } });
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("name-session")]),
heartbeatIntervalMs: 60_000,
@@ -118,6 +125,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("status-session")]),
heartbeatIntervalMs: 60_000,
@@ -139,6 +147,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("dedupe-session")]),
heartbeatIntervalMs: 60_000,
@@ -155,6 +164,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const fake = fakeRuntime("queued-session", { isStreaming: true });
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("queued-session")]),
heartbeatIntervalMs: 60_000,
@@ -181,6 +191,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
};
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("compacting-session")]),
heartbeatIntervalMs: 60_000,
@@ -250,6 +261,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
fake.session.clearQueue = clearRuntimeQueue;
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("clear-queue-session")]),
heartbeatIntervalMs: 60_000,
@@ -290,6 +302,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const fake = fakeRuntime("clear-empty-queue-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("clear-empty-queue-session")]),
heartbeatIntervalMs: 60_000,
@@ -309,6 +322,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const fake = fakeRuntime("abort-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("abort-session")]),
heartbeatIntervalMs: 60_000,
@@ -326,6 +340,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const fake = fakeRuntime("abort-compaction-session", { isCompacting: true });
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]),
heartbeatIntervalMs: 60_000,
@@ -341,17 +356,66 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
await service.dispose();
});
it("reloads models.json before listing and selecting models", async () => {
const agentDir = await mkdtemp(join(tmpdir(), "pi-web-model-runtime-"));
try {
const modelsPath = join(agentDir, "models.json");
await writeLocalModelsConfig(modelsPath, "initial-model");
const modelRuntime = await ModelRuntime.create({
credentials: new InMemoryCredentialStore(),
modelsPath,
allowModelNetwork: false,
});
const setSessionModel = vi.fn(() => Promise.resolve());
const fake = fakeRuntime("models-session", { modelRuntime, setModel: setSessionModel });
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir,
modelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("models-session")]),
heartbeatIntervalMs: 60_000,
});
try {
await writeLocalModelsConfig(modelsPath, "listed-model");
const listed = await service.availableModels(sessionRef("models-session"));
expect(listed).toEqual(expect.arrayContaining([
expect.objectContaining({ provider: "test-local", id: "listed-model" }),
]));
expect(listed).not.toEqual(expect.arrayContaining([
expect.objectContaining({ provider: "test-local", id: "initial-model" }),
]));
await writeLocalModelsConfig(modelsPath, "selected-model");
await expect(service.setModel(sessionRef("models-session"), "test-local", "selected-model")).resolves.toBeDefined();
expect(setSessionModel).toHaveBeenCalledWith(expect.objectContaining({
provider: "test-local",
id: "selected-model",
}));
} finally {
await service.dispose();
}
} finally {
await rm(agentDir, { recursive: true, force: true });
}
});
it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => {
const hub = new CapturingSessionEventHub();
const authStorage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "sk-test" } });
const modelRegistry = ModelRegistry.inMemory(authStorage);
const model = modelRegistry.find(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
// The shared model runtime reads a live credential store. Mutating the store
// and refreshing here simulates the committed snapshot that
// ModelRuntime.login()/logout() establishes before AuthService emits.
// applyAuthChange then only needs to notify active sessions.
const credentials = new InMemoryCredentialStore();
await seedCredential(credentials, "anthropic", { type: "api_key", key: "sk-test" });
const modelRuntime = await createTestModelRuntime(credentials);
const model = modelRuntime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
if (model === undefined) throw new Error("Expected Anthropic model fixture");
const fake = fakeRuntime("auth-session", { model, modelRegistry });
const fake = fakeRuntime("auth-session", { model, modelRuntime });
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRegistry,
modelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("auth-session")]),
heartbeatIntervalMs: 60_000,
@@ -361,7 +425,8 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
hub.sessionEvents.length = 0;
hub.globalEvents.length = 0;
authStorage.logout("anthropic");
await credentials.delete("anthropic");
await modelRuntime.refresh();
service.applyAuthChange({ removedProviderId: "anthropic" });
service.applyAuthChange({ removedProviderId: "anthropic" });
@@ -369,9 +434,11 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
expect(warningCount()).toBe(1);
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "auth-session")).toBe(true);
authStorage.set("anthropic", { type: "api_key", key: "sk-new" });
await seedCredential(credentials, "anthropic", { type: "api_key", key: "sk-new" });
await modelRuntime.refresh();
service.applyAuthChange();
authStorage.logout("anthropic");
await credentials.delete("anthropic");
await modelRuntime.refresh();
service.applyAuthChange({ removedProviderId: "anthropic" });
expect(warningCount()).toBe(2);
@@ -382,6 +449,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const fake = fakeRuntime("stop-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("stop-session")]),
heartbeatIntervalMs: 60_000,
@@ -394,3 +462,25 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
await service.dispose();
});
});
async function writeLocalModelsConfig(path: string, modelId: string): Promise<void> {
await writeFile(path, JSON.stringify({
providers: {
"test-local": {
name: "Test Local",
baseUrl: "http://127.0.0.1:1234/v1",
apiKey: "offline-test-key",
api: "openai-completions",
models: [{
id: modelId,
name: modelId,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_000,
maxTokens: 100,
}],
},
},
}));
}
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
@@ -12,6 +12,7 @@ describe("PiSessionService", () => {
const log: { details: Record<string, unknown>; message: string }[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
@@ -45,6 +46,7 @@ describe("PiSessionService", () => {
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
@@ -80,6 +82,7 @@ describe("PiSessionService", () => {
const fake = fakeRuntime("spawned-x");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -4,7 +4,7 @@ import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
@@ -40,6 +40,7 @@ describe("PiSessionService", () => {
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: sessionGateway([]),
archiveStore,
@@ -81,6 +82,7 @@ describe("PiSessionService", () => {
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: sessionGateway([]),
archiveStore: emptyArchiveStore(),
@@ -121,6 +123,7 @@ describe("PiSessionService", () => {
let index = 0;
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? child.runtime;
index += 1;
@@ -173,6 +176,7 @@ describe("PiSessionService", () => {
const open = vi.fn(() => childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? child.runtime;
index += 1;
@@ -215,6 +219,7 @@ describe("PiSessionService", () => {
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
@@ -240,6 +245,7 @@ describe("PiSessionService", () => {
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
@@ -262,6 +268,7 @@ describe("PiSessionService", () => {
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
@@ -283,6 +290,7 @@ describe("PiSessionService", () => {
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
@@ -304,6 +312,7 @@ describe("PiSessionService", () => {
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(forkedParent.runtime),
sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
@@ -343,6 +352,7 @@ describe("PiSessionService", () => {
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: (_createRuntime, options) => {
delegationCapabilities.push(options.delegationToolsEnabled);
const runtime = runtimes[index] ?? parent.runtime;
@@ -409,6 +419,7 @@ describe("PiSessionService", () => {
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
@@ -469,6 +480,7 @@ describe("PiSessionService", () => {
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
@@ -536,6 +548,7 @@ describe("PiSessionService", () => {
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: {
create: () => parentManager,
@@ -614,6 +627,7 @@ describe("PiSessionService", () => {
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: {
create: () => copiedParentManager,
@@ -674,6 +688,7 @@ describe("PiSessionService", () => {
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
@@ -728,6 +743,7 @@ describe("PiSessionService", () => {
const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
@@ -770,6 +786,7 @@ describe("PiSessionService", () => {
const open = vi.fn(() => childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(child.runtime),
sessionManager: {
create: () => childManager,
@@ -959,6 +976,7 @@ describe("PiSessionService", () => {
const fake = fakeRuntime("nope");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -1,4 +1,5 @@
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { InMemoryCredentialStore, type Credential, type CredentialStore } from "@earendil-works/pi-ai";
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import type { PiAgentSession, PiSessionManager, PiSessionRuntime, PiSessionServiceDependencies } from "./piSessionService.js";
@@ -62,8 +63,34 @@ export function sessionRef(id: string, cwd = "/workspace") {
export const TEST_MODEL_PROVIDER = "anthropic";
export const TEST_MODEL_ID = "claude-sonnet-4-5-20250929";
/**
* Seed a credential into an {@link InMemoryCredentialStore}. `modify` is the
* only write path on the pi-ai `CredentialStore` contract, so tests that need a
* pre-populated store go through it rather than mutating internals.
*/
export async function seedCredential(store: InMemoryCredentialStore, providerId: string, credential: Credential): Promise<void> {
await store.modify(providerId, () => Promise.resolve(credential));
}
/**
* Build a real {@link ModelRuntime} over an in-memory credential store — the
* async test seam that replaces the removed `ModelRegistry.create(AuthStorage
* .inMemory())`. Pass a pre-seeded store to exercise credential-dependent
* behavior (e.g. auth-loss warnings).
*/
export function createTestModelRuntime(credentials: CredentialStore = new InMemoryCredentialStore()): Promise<ModelRuntime> {
return ModelRuntime.create({ credentials, modelsPath: null, allowModelNetwork: false });
}
/**
* Shared runtime for the common case where a test only needs model catalog
* reads and no configured auth. Built once so the many `fakeRuntime` sessions
* and `PiSessionService` constructions can inject it synchronously.
*/
export const testModelRuntime = await createTestModelRuntime();
export function testModel(): NonNullable<PiAgentSession["model"]> {
const model = ModelRegistry.inMemory(AuthStorage.inMemory()).find(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
const model = testModelRuntime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
if (model === undefined) throw new Error("test model not found");
return model;
}
@@ -88,7 +115,7 @@ export function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession>
pendingMessageCount: 0,
sessionManager: fakeSessionManager(),
settingsManager: { getWarnings: () => ({}), setWarnings: () => undefined },
modelRegistry: ModelRegistry.create(AuthStorage.inMemory()),
modelRuntime: testModelRuntime,
scopedModels: [],
extensionRunner: { getRegisteredCommands: () => [] },
promptTemplates: [],
+25 -25
View File
@@ -1,20 +1,21 @@
import { statSync } from "node:fs";
import { join } from "node:path";
import { open, readFile, writeFile } from "node:fs/promises";
import type { ImageContent } from "@earendil-works/pi-ai";
import type { StreamFn } from "@earendil-works/pi-agent-core";
import {
AuthStorage,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
createEditToolDefinition,
defineTool,
ModelRegistry,
readStoredCredential,
SessionManager,
type AgentSessionRuntimeDiagnostic,
type AgentSessionServices,
type CreateAgentSessionRuntimeFactory,
type EditToolDetails,
type ModelRuntime,
type ResourceDiagnostic,
} from "@earendil-works/pi-coding-agent";
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionStreamSnapshot, SessionUiEvent } from "../types.js";
@@ -26,7 +27,6 @@ import { SessionCommandService } from "./sessionCommandService.js";
import { SessionArchiveStore, type ArchivedSessionRecord, type ArchiveSessionInput } from "./sessionArchiveStore.js";
import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree, type SessionArchiveTreeCandidate } from "./sessionArchiveTree.js";
import type { ActiveSession } from "./sessionRuntimeStore.js";
import { createModelRegistryForAgentDir, type AuthChange } from "./authService.js";
import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
@@ -34,6 +34,7 @@ import { parsePromptAttachments } from "../../shared/promptAttachments.js";
import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionWarning } from "../../shared/apiTypes.js";
import type { SessionRouteLookup, SessionRouteRef, SessionRouteService } from "./sessionService.js";
import { type AuthChange } from "./authService.js";
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
@@ -171,7 +172,6 @@ interface BulkDeletePlanItem {
}
type AgentModel = NonNullable<SpawnSessionInvocation["model"]>;
type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
export interface PiSessionManager {
getCwd(): string;
@@ -208,7 +208,7 @@ interface PiExtensionBindings {
}
export interface PiAgentSession {
modelRegistry: ModelRegistryInstance;
modelRuntime: ModelRuntime;
/**
* Narrow read/write of the SDK `SettingsManager`, exposing only the warning
* suppression flags consumed here (e.g. `anthropicExtraUsage`). Used to gate
@@ -383,11 +383,12 @@ const ANTHROPIC_EXTRA_USAGE_DISMISS_ID = "anthropicExtraUsage";
* synchronous live status computation.
*/
export function anthropicSubscriptionWarning(
session: Pick<PiAgentSession, "model" | "modelRegistry" | "settingsManager">,
session: Pick<PiAgentSession, "model" | "settingsManager">,
authPath?: string,
): SessionWarning | undefined {
if (session.settingsManager.getWarnings().anthropicExtraUsage === false) return undefined;
if (session.model?.provider !== "anthropic") return undefined;
const credential = session.modelRegistry.authStorage.get("anthropic");
const credential = readStoredCredential("anthropic", authPath);
if (credential === undefined) return undefined;
const isSubscriptionAuth = credential.type === "oauth"
? true
@@ -487,14 +488,13 @@ export function createPiWebCustomToolDefinitions(
}
function createDefaultRuntimeFactory(
authStorage: AuthStorage,
modelRegistry: ModelRegistryInstance,
modelRuntime: ModelRuntime,
sessionManagers: Pick<PiSessionManagerGateway, "open">,
spawn?: SpawnSessionFn,
subsessions?: SubsessionToolDeps,
): PiWebCreateAgentSessionRuntimeFactory {
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => {
const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry });
const services = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
const resolvedDelegationToolsEnabled = delegationToolsEnabled
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions);
@@ -539,7 +539,7 @@ export interface PiSessionServiceDependencies {
archiveStore?: SessionArchiveRepository;
createRuntime?: PiWebCreateAgentSessionRuntimeFactory;
createAgentRuntime?: CreateAgentRuntime;
modelRegistry?: ModelRegistryInstance;
modelRuntime: ModelRuntime;
heartbeatIntervalMs?: number;
workspaceActivity?: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity">;
/**
@@ -589,7 +589,7 @@ export class PiSessionService implements SessionRouteService {
private readonly sessionManager: PiSessionManagerGateway;
private readonly createRuntime: PiWebCreateAgentSessionRuntimeFactory;
private readonly createAgentRuntime: CreateAgentRuntime;
private readonly modelRegistry: ModelRegistryInstance;
private readonly modelRuntime: ModelRuntime;
private readonly workspaceActivity: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity"> | undefined;
private readonly spawnTargets: SpawnTargetResolver | undefined;
private readonly logger: PiSessionLogger;
@@ -599,7 +599,7 @@ export class PiSessionService implements SessionRouteService {
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
this.agentDir = deps.agentDir;
this.sessionManager = deps.sessionManager;
this.modelRegistry = deps.modelRegistry ?? createModelRegistryForAgentDir(this.agentDir);
this.modelRuntime = deps.modelRuntime;
this.spawnTargets = deps.spawnTargets;
this.logger = deps.logger ?? noopLogger;
this.now = deps.now ?? (() => new Date());
@@ -607,8 +607,7 @@ export class PiSessionService implements SessionRouteService {
// also require the spawn capability (they share its project-scope resolver).
const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(
this.modelRegistry.authStorage,
this.modelRegistry,
this.modelRuntime,
this.sessionManager,
this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input),
!subsessionsActive ? undefined : {
@@ -1159,22 +1158,22 @@ export class PiSessionService implements SessionRouteService {
async availableModels(ref: PiSessionLookup): Promise<ClientSessionModel[]> {
const session = await this.getOrOpen(ref);
session.modelRegistry.refresh();
await session.modelRuntime.reloadConfig();
const models = session.scopedModels.length > 0
? session.scopedModels.map((scoped) => scoped.model)
: session.modelRegistry.getAvailable();
: session.modelRuntime.getAvailableSnapshot();
return models.map(modelToClientModel);
}
async setModel(ref: PiSessionLookup, provider: string, modelId: string): Promise<ClientSessionStatus> {
await this.assertWritable(ref);
const session = await this.getOrOpen(ref);
session.modelRegistry.refresh();
await session.modelRuntime.reloadConfig();
const candidates = session.scopedModels.length > 0
? session.scopedModels.map((scoped) => scoped.model)
: session.modelRegistry.getAvailable();
: session.modelRuntime.getAvailableSnapshot();
const model = candidates.find((candidate) => candidate.provider === provider && candidate.id === modelId)
?? session.modelRegistry.find(provider, modelId);
?? session.modelRuntime.getModel(provider, modelId);
if (model === undefined) throw new Error(`Model not found: ${provider}/${modelId}`);
await session.setModel(model);
this.publishActivity(session, `model: ${model.id}`, "idle", model.provider);
@@ -2020,10 +2019,11 @@ export class PiSessionService implements SessionRouteService {
}
applyAuthChange(change: AuthChange = {}): void {
this.modelRegistry.refresh();
// ModelRuntime.login()/logout() refresh the shared runtime before AuthService
// emits the change, so no refresh is needed here. Keeping this synchronous
// also lets every active session observe the same committed auth snapshot.
for (const active of this.active.values()) {
const { session } = active.runtime;
session.modelRegistry.refresh();
this.syncCurrentModelAuthWarning(session, change.removedProviderId);
this.publishStatus(session);
}
@@ -2034,9 +2034,9 @@ export class PiSessionService implements SessionRouteService {
if (model === undefined) return;
if (model.provider === "unknown" && model.id === "unknown") return;
const warningKey = authLossWarningKey(session.sessionId, model.provider, model.id);
const registered = session.modelRegistry.find(model.provider, model.id);
const registered = session.modelRuntime.getModel(model.provider, model.id);
if (registered === undefined) return;
if (session.modelRegistry.hasConfiguredAuth(registered)) {
if (session.modelRuntime.hasConfiguredAuth(model.provider)) {
this.authLossWarnings.delete(warningKey);
return;
}
@@ -2182,7 +2182,7 @@ export class PiSessionService implements SessionRouteService {
private warningsForSession(session: PiAgentSession): SessionWarning[] {
const runtime = this.active.get(session.sessionId)?.runtime;
const warnings = runtime === undefined ? [] : collectRuntimeWarnings(runtime);
const anthropic = anthropicSubscriptionWarning(session);
const anthropic = anthropicSubscriptionWarning(session, join(this.agentDir, "auth.json"));
if (anthropic !== undefined) warnings.push(anthropic);
return warnings;
}
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { AuthStorage, ModelRegistry, type AgentSessionRuntimeDiagnostic, type ResourceDiagnostic } from "@earendil-works/pi-coding-agent";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { type AgentSessionRuntimeDiagnostic, type ResourceDiagnostic } from "@earendil-works/pi-coding-agent";
import { anthropicSubscriptionWarning, collectRuntimeWarnings, dismissSessionWarning, type RuntimeWarningSources } from "./piSessionService.js";
import { testModel } from "./piSessionService.testSupport.js";
import type { PiAgentSession } from "./piSessionService.js";
import type { SessionWarning } from "../../shared/apiTypes.js";
@@ -83,47 +87,55 @@ describe("collectRuntimeWarnings", () => {
const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING =
"Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage.";
type SubscriptionSession = Pick<PiAgentSession, "model" | "modelRegistry" | "settingsManager">;
type SubscriptionSession = Pick<PiAgentSession, "model" | "settingsManager">;
function anthropicModel(provider: string): PiAgentSession["model"] {
const registry = ModelRegistry.inMemory(AuthStorage.inMemory());
const model = registry.getAll().find((candidate) => candidate.provider === provider) ?? registry.getAll()[0];
if (model === undefined) throw new Error("expected at least one built-in model");
return { ...model, provider };
// anthropicSubscriptionWarning only reads `model.provider`, so any built-in
// model re-tagged with the desired provider is a sufficient fixture.
return { ...testModel(), provider };
}
function subscriptionSession(options: {
provider?: string;
anthropicExtraUsage?: boolean;
credential?: AuthStorage;
}): SubscriptionSession {
const authStorage = options.credential ?? AuthStorage.inMemory();
return {
model: options.provider === undefined ? undefined : anthropicModel(options.provider),
settingsManager: {
getWarnings: () => (options.anthropicExtraUsage === undefined ? {} : { anthropicExtraUsage: options.anthropicExtraUsage }),
setWarnings: () => undefined,
},
modelRegistry: ModelRegistry.create(authStorage),
};
}
function anthropicAuth(credential: { type: "oauth" } | { type: "api_key"; key: string }): AuthStorage {
const authStorage = AuthStorage.inMemory();
if (credential.type === "oauth") {
authStorage.set("anthropic", { type: "oauth", access: "a", refresh: "r", expires: Date.now() + 3_600_000 });
} else {
authStorage.set("anthropic", { type: "api_key", key: credential.key });
}
return authStorage;
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
/**
* Write an `auth.json` holding a single anthropic credential and return its
* path. `anthropicSubscriptionWarning` reads it via `readStoredCredential`, so
* the credential seam is the on-disk auth file rather than an in-memory store.
*/
async function anthropicAuthPath(credential: { type: "oauth" } | { type: "api_key"; key: string }): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "pi-web-warnings-"));
tempDirs.push(dir);
const authPath = join(dir, "auth.json");
const stored = credential.type === "oauth"
? { type: "oauth", access: "a", refresh: "r", expires: Date.now() + 3_600_000 }
: { type: "api_key", key: credential.key };
await writeFile(authPath, JSON.stringify({ anthropic: stored }));
return authPath;
}
describe("anthropicSubscriptionWarning", () => {
it("warns with the verbatim SDK wording for a stored oauth credential", () => {
expect(anthropicSubscriptionWarning(subscriptionSession({
provider: "anthropic",
credential: anthropicAuth({ type: "oauth" }),
}))).toEqual({
it("warns with the verbatim SDK wording for a stored oauth credential", async () => {
expect(anthropicSubscriptionWarning(
subscriptionSession({ provider: "anthropic" }),
await anthropicAuthPath({ type: "oauth" }),
)).toEqual({
severity: "warning",
message: ANTHROPIC_SUBSCRIPTION_AUTH_WARNING,
source: "anthropic",
@@ -131,37 +143,41 @@ describe("anthropicSubscriptionWarning", () => {
} satisfies SessionWarning);
});
it("warns for an sk-ant-oat subscription API key", () => {
expect(anthropicSubscriptionWarning(subscriptionSession({
provider: "anthropic",
credential: anthropicAuth({ type: "api_key", key: "sk-ant-oat-abc123" }),
}))?.message).toBe(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING);
it("warns for an sk-ant-oat subscription API key", async () => {
expect(anthropicSubscriptionWarning(
subscriptionSession({ provider: "anthropic" }),
await anthropicAuthPath({ type: "api_key", key: "sk-ant-oat-abc123" }),
)?.message).toBe(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING);
});
it("does not warn for a standard anthropic API key", () => {
expect(anthropicSubscriptionWarning(subscriptionSession({
provider: "anthropic",
credential: anthropicAuth({ type: "api_key", key: "sk-ant-api-abc123" }),
}))).toBeUndefined();
it("does not warn for a standard anthropic API key", async () => {
expect(anthropicSubscriptionWarning(
subscriptionSession({ provider: "anthropic" }),
await anthropicAuthPath({ type: "api_key", key: "sk-ant-api-abc123" }),
)).toBeUndefined();
});
it("respects the anthropicExtraUsage suppression gate", () => {
expect(anthropicSubscriptionWarning(subscriptionSession({
provider: "anthropic",
anthropicExtraUsage: false,
credential: anthropicAuth({ type: "oauth" }),
}))).toBeUndefined();
it("respects the anthropicExtraUsage suppression gate", async () => {
expect(anthropicSubscriptionWarning(
subscriptionSession({ provider: "anthropic", anthropicExtraUsage: false }),
await anthropicAuthPath({ type: "oauth" }),
)).toBeUndefined();
});
it("does not warn when the active provider is not anthropic", () => {
expect(anthropicSubscriptionWarning(subscriptionSession({
provider: "openai",
credential: anthropicAuth({ type: "oauth" }),
}))).toBeUndefined();
it("does not warn when the active provider is not anthropic", async () => {
expect(anthropicSubscriptionWarning(
subscriptionSession({ provider: "openai" }),
await anthropicAuthPath({ type: "oauth" }),
)).toBeUndefined();
});
it("does not warn when no anthropic credential is stored", () => {
expect(anthropicSubscriptionWarning(subscriptionSession({ provider: "anthropic" }))).toBeUndefined();
it("does not warn when no anthropic credential is stored", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-web-warnings-"));
tempDirs.push(dir);
expect(anthropicSubscriptionWarning(
subscriptionSession({ provider: "anthropic" }),
join(dir, "auth.json"),
)).toBeUndefined();
});
});
+2 -1
View File
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionStatus, SessionStreamSnapshot } from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js";
import { testModelRuntime } from "./piSessionService.testSupport.js";
import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js";
import { registerSessionRoutes } from "./sessionRoutes.js";
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
@@ -20,7 +21,7 @@ beforeEach(async () => {
await app.register(fastifyWebsocket);
sessionManager = new RejectingSessionManager();
const eventHub = new SessionEventHub();
service = new PiSessionService(eventHub, { agentDir: TEST_AGENT_DIR, sessionManager, heartbeatIntervalMs: 60_000 });
service = new PiSessionService(eventHub, { agentDir: TEST_AGENT_DIR, modelRuntime: testModelRuntime, sessionManager, heartbeatIntervalMs: 60_000 });
registerSessionRoutes(app, service, eventHub);
});
+17 -2
View File
@@ -373,6 +373,8 @@ export interface AuthProviderOption {
name: string;
authType: AuthType;
status: AuthProviderStatus;
/** Additive hint: use the generic AuthInteraction transport instead of the legacy one-secret form. */
loginFlow?: "interactive";
}
export interface AuthProvidersResponse {
@@ -384,10 +386,23 @@ export interface OAuthFlowState {
providerId: string;
providerName: string;
status: "running" | "complete" | "error" | "cancelled";
auth?: { url: string; instructions?: string };
prompt?: { requestId: string; message: string; placeholder?: string; allowEmpty?: boolean; kind: "prompt" | "manual" };
auth?: {
url: string;
instructions?: string;
deviceCode?: { userCode: string; intervalSeconds?: number; expiresInSeconds?: number };
};
prompt?: {
requestId: string;
message: string;
placeholder?: string;
allowEmpty?: boolean;
/** Additive semantic detail; legacy peers continue to use `kind`. */
promptType?: "text" | "secret" | "manual_code";
kind: "prompt" | "manual";
};
select?: { requestId: string; message: string; options: CommandOption[] };
progress: string[];
info?: { message: string; links?: { url: string; label?: string }[] }[];
error?: string;
}
+1
View File
@@ -76,6 +76,7 @@ export const FEDERATED_HTTP_ROUTES = [
{ method: "POST", path: "/sessions/:sessionId/detach-parent" },
{ method: "GET", path: "/auth/providers" },
{ method: "POST", path: "/auth/api-key" },
{ method: "POST", path: "/auth/api-key/interactive" },
{ method: "POST", path: "/auth/logout" },
{ method: "POST", path: "/auth/oauth" },
{ method: "GET", path: "/auth/oauth/:flowId" },