Archived
Merge pull request #64 from jmfederico/fix/issue-62-authstorage
fix: migrate auth/model plumbing to ModelRuntime (fixes #62)
This commit is contained in:
@@ -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`.
|
||||||
@@ -34,9 +34,9 @@ Your browser is the control surface. The work stays where it can keep running.
|
|||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
|
|
||||||
- Node.js 22 or newer
|
- Node.js 22.19.0 or newer
|
||||||
- npm
|
- 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
|
- git and the development tools your agents need
|
||||||
|
|
||||||
Install and start PI WEB as per-user services:
|
Install and start PI WEB as per-user services:
|
||||||
|
|||||||
+2
-2
@@ -157,7 +157,7 @@
|
|||||||
<article id="doctor-fails" class="faq-item">
|
<article id="doctor-fails" class="faq-item">
|
||||||
<h2>What does <code>pi-web doctor</code> check?</h2>
|
<h2>What does <code>pi-web doctor</code> check?</h2>
|
||||||
<p>
|
<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
|
ripgrep. Native-service diagnostics validate only the exact prerequisites of the selected service plan in
|
||||||
the real systemd user-manager or launchd <code>gui/<uid></code> context. Development installs follow their
|
the real systemd user-manager or launchd <code>gui/<uid></code> context. Development installs follow their
|
||||||
installed checkout plan; production checks are clearly labelled prospective when the installed executable
|
installed checkout plan; production checks are clearly labelled prospective when the installed executable
|
||||||
@@ -181,7 +181,7 @@
|
|||||||
<ul>
|
<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>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>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>
|
<li>Run <code>pi-web doctor</code> again after changing shell files.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
+2
-2
@@ -38,7 +38,7 @@
|
|||||||
"downloadUrl": "https://www.npmjs.com/package/@jmfederico/pi-web",
|
"downloadUrl": "https://www.npmjs.com/package/@jmfederico/pi-web",
|
||||||
"codeRepository": "https://github.com/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.",
|
"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"
|
"license": "https://github.com/jmfederico/pi-web/blob/main/LICENSE"
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -138,7 +138,7 @@
|
|||||||
# laptop, phone, tablet — same live sessions
|
# laptop, phone, tablet — same live sessions
|
||||||
|
|
||||||
<span class="prompt">$</span> pi-web doctor
|
<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
|
✓ native-service plan requirements pass in manager context
|
||||||
✓ ready for persistent agent work</code></pre>
|
✓ ready for persistent agent work</code></pre>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
+2
-2
@@ -106,8 +106,8 @@
|
|||||||
<section id="requirements">
|
<section id="requirements">
|
||||||
<h2>Requirements</h2>
|
<h2>Requirements</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Node.js 22 or newer</strong> and npm.</li>
|
<li><strong>Node.js 22.19.0 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>Pi Coding Agent <code>>=0.80.8 <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>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>
|
<li>For the automatic installer: a supported per-user service manager.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
Generated
+215
-197
@@ -42,9 +42,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@changesets/cli": "^2.31.0",
|
"@changesets/cli": "^2.31.0",
|
||||||
"@earendil-works/pi-agent-core": "^0.80.6",
|
"@earendil-works/pi-agent-core": "^0.80.8",
|
||||||
"@earendil-works/pi-ai": "^0.80.6",
|
"@earendil-works/pi-ai": "^0.80.8",
|
||||||
"@earendil-works/pi-coding-agent": "^0.80.6",
|
"@earendil-works/pi-coding-agent": "^0.80.8",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
@@ -58,12 +58,12 @@
|
|||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22.19.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@earendil-works/pi-agent-core": ">=0.80.0 <1",
|
"@earendil-works/pi-agent-core": ">=0.80.8 <0.81",
|
||||||
"@earendil-works/pi-ai": ">=0.80.0 <1",
|
"@earendil-works/pi-ai": ">=0.80.8 <0.81",
|
||||||
"@earendil-works/pi-coding-agent": ">=0.80.0 <1"
|
"@earendil-works/pi-coding-agent": ">=0.80.8 <0.81"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@anthropic-ai/sdk": {
|
"node_modules/@anthropic-ai/sdk": {
|
||||||
@@ -167,18 +167,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/core": {
|
"node_modules/@aws-sdk/core": {
|
||||||
"version": "3.975.1",
|
"version": "3.975.3",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.1.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.3.tgz",
|
||||||
"integrity": "sha512-8qh/6EYb7hl/ZwVfQufhbMEZs1gQIc7GbdrIf4eprQJ7cv042+74nE6l3YDfyWNzb9iPXb8fRyYSHkNIk5eE6Q==",
|
"integrity": "sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/types": "^3.974.0",
|
"@aws-sdk/types": "^3.974.2",
|
||||||
"@aws-sdk/xml-builder": "^3.972.34",
|
"@aws-sdk/xml-builder": "^3.972.36",
|
||||||
"@aws/lambda-invoke-store": "^0.3.0",
|
"@aws/lambda-invoke-store": "^0.3.0",
|
||||||
"@smithy/core": "^3.29.2",
|
"@smithy/core": "^3.29.4",
|
||||||
"@smithy/signature-v4": "^5.6.3",
|
"@smithy/signature-v4": "^5.6.5",
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"bowser": "^2.11.0",
|
"bowser": "^2.11.0",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
@@ -187,16 +187,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-env": {
|
"node_modules/@aws-sdk/credential-provider-env": {
|
||||||
"version": "3.972.57",
|
"version": "3.972.59",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.57.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.59.tgz",
|
||||||
"integrity": "sha512-1RfJaF7SW1TOnvNGU7kaYjwUf5H3sfm+synGH1bHhRlqcnxCt3szebH3dmKEyY4tuGcbQ6ffzUT89cRitBV8OQ==",
|
"integrity": "sha512-Ny5e4Mfh3QPmiAc0AiUe+cbTXDlxkU3Rc+EpWOfyWeWEy6yp7Fa1KmfNeCc+1a8by9zQ9gtohmiQUkMPScF3ng==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "^3.975.1",
|
"@aws-sdk/core": "^3.975.3",
|
||||||
"@aws-sdk/types": "^3.974.0",
|
"@aws-sdk/types": "^3.974.2",
|
||||||
"@smithy/core": "^3.29.2",
|
"@smithy/core": "^3.29.4",
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -204,18 +204,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-http": {
|
"node_modules/@aws-sdk/credential-provider-http": {
|
||||||
"version": "3.972.59",
|
"version": "3.972.61",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.59.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.61.tgz",
|
||||||
"integrity": "sha512-sRCkpTiFnCdQvuaRVjQ6SVoHu6i7RUpurVo1c4F81HWhPvUJ7Wdp5MNtSdX1O29CNXc8em3O5m52hCjVtAD9SA==",
|
"integrity": "sha512-8jAjgStl5Ytq4+HF3X/9f+EmRinaRbGRRtQGktlPfBRVx73H+R1y48vIeXerQtYGFaUqkEp3fT6jP854rVO2yQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "^3.975.1",
|
"@aws-sdk/core": "^3.975.3",
|
||||||
"@aws-sdk/types": "^3.974.0",
|
"@aws-sdk/types": "^3.974.2",
|
||||||
"@smithy/core": "^3.29.2",
|
"@smithy/core": "^3.29.4",
|
||||||
"@smithy/fetch-http-handler": "^5.6.4",
|
"@smithy/fetch-http-handler": "^5.6.6",
|
||||||
"@smithy/node-http-handler": "^4.9.4",
|
"@smithy/node-http-handler": "^4.9.6",
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -223,14 +223,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": {
|
"node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": {
|
||||||
"version": "4.9.4",
|
"version": "4.9.7",
|
||||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.7.tgz",
|
||||||
"integrity": "sha512-BNTop/fSOptmoVk8g+efwHCofFh37g70OWGAFES1TeAAJja1K5aAI8rTE26ETSc5k8IQuWY2kAIoPla01NgYrA==",
|
"integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@smithy/core": "^3.29.2",
|
"@smithy/core": "^3.29.5",
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -238,24 +238,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-ini": {
|
"node_modules/@aws-sdk/credential-provider-ini": {
|
||||||
"version": "3.973.1",
|
"version": "3.973.4",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.1.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.4.tgz",
|
||||||
"integrity": "sha512-6d8H6ZAh3ZPKZ6fe1nG2OWeZEZPtt9ravoD1dezPdPtsSkJRoxGAnFSHwKT3E/Te6fHE30zRzjV6TD12rvF6yQ==",
|
"integrity": "sha512-e6ZvVsj90aRALf1kHP+J4iqC1496ZpVgqI/+u0LJ5HL7q7ATauGy4gdDvRCP13L1pN/fMiZLah162PGIYkbUVQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "^3.975.1",
|
"@aws-sdk/core": "^3.975.3",
|
||||||
"@aws-sdk/credential-provider-env": "^3.972.57",
|
"@aws-sdk/credential-provider-env": "^3.972.59",
|
||||||
"@aws-sdk/credential-provider-http": "^3.972.59",
|
"@aws-sdk/credential-provider-http": "^3.972.61",
|
||||||
"@aws-sdk/credential-provider-login": "^3.972.63",
|
"@aws-sdk/credential-provider-login": "^3.972.66",
|
||||||
"@aws-sdk/credential-provider-process": "^3.972.57",
|
"@aws-sdk/credential-provider-process": "^3.972.59",
|
||||||
"@aws-sdk/credential-provider-sso": "^3.973.1",
|
"@aws-sdk/credential-provider-sso": "^3.973.3",
|
||||||
"@aws-sdk/credential-provider-web-identity": "^3.972.63",
|
"@aws-sdk/credential-provider-web-identity": "^3.972.65",
|
||||||
"@aws-sdk/nested-clients": "^3.997.31",
|
"@aws-sdk/nested-clients": "^3.997.33",
|
||||||
"@aws-sdk/types": "^3.974.0",
|
"@aws-sdk/types": "^3.974.2",
|
||||||
"@smithy/core": "^3.29.2",
|
"@smithy/core": "^3.29.4",
|
||||||
"@smithy/credential-provider-imds": "^4.4.7",
|
"@smithy/credential-provider-imds": "^4.4.9",
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -263,17 +263,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-login": {
|
"node_modules/@aws-sdk/credential-provider-login": {
|
||||||
"version": "3.972.63",
|
"version": "3.972.66",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.63.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.66.tgz",
|
||||||
"integrity": "sha512-GREWRrMj0XnNKMaVa/Mauoaui26qBEHu71WWqXbwZOu/jFQOnPZjTf7u0KtGKC8VGa6VUs9kDWGgocrKNLS9vw==",
|
"integrity": "sha512-g2fsqm87r/nKthLZ0VkkDBElkGg0PvSa8d97HQ6EilMbJTZ6hxa8FxkSZyJfgPfFdZn0TTmkOffQmTSUcAHIng==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "^3.975.1",
|
"@aws-sdk/core": "^3.975.3",
|
||||||
"@aws-sdk/nested-clients": "^3.997.31",
|
"@aws-sdk/nested-clients": "^3.997.33",
|
||||||
"@aws-sdk/types": "^3.974.0",
|
"@aws-sdk/types": "^3.974.2",
|
||||||
"@smithy/core": "^3.29.2",
|
"@smithy/core": "^3.29.4",
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -281,22 +281,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-node": {
|
"node_modules/@aws-sdk/credential-provider-node": {
|
||||||
"version": "3.972.66",
|
"version": "3.972.70",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.66.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.70.tgz",
|
||||||
"integrity": "sha512-f+qjRXZpz7sgzbc4QB+6nLKfyKFgRRXzWdXbsKPv/VhVRyHsDyq4yBWC/B75BAJpFIcUeI2XR/3gdWJ677zB4A==",
|
"integrity": "sha512-3xzvkGdykBunxqh8WudmUpSyLWvIhfI6aBQo1b5rb3mDO5mNLadK+0hiI0qBQBMVynJbfLO+Ajy9dztMwy9O8w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/credential-provider-env": "^3.972.57",
|
"@aws-sdk/credential-provider-env": "^3.972.59",
|
||||||
"@aws-sdk/credential-provider-http": "^3.972.59",
|
"@aws-sdk/credential-provider-http": "^3.972.61",
|
||||||
"@aws-sdk/credential-provider-ini": "^3.973.1",
|
"@aws-sdk/credential-provider-ini": "^3.973.4",
|
||||||
"@aws-sdk/credential-provider-process": "^3.972.57",
|
"@aws-sdk/credential-provider-process": "^3.972.59",
|
||||||
"@aws-sdk/credential-provider-sso": "^3.973.1",
|
"@aws-sdk/credential-provider-sso": "^3.973.3",
|
||||||
"@aws-sdk/credential-provider-web-identity": "^3.972.63",
|
"@aws-sdk/credential-provider-web-identity": "^3.972.65",
|
||||||
"@aws-sdk/types": "^3.974.0",
|
"@aws-sdk/types": "^3.974.2",
|
||||||
"@smithy/core": "^3.29.2",
|
"@smithy/core": "^3.29.4",
|
||||||
"@smithy/credential-provider-imds": "^4.4.7",
|
"@smithy/credential-provider-imds": "^4.4.9",
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -304,16 +304,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-process": {
|
"node_modules/@aws-sdk/credential-provider-process": {
|
||||||
"version": "3.972.57",
|
"version": "3.972.59",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.57.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.59.tgz",
|
||||||
"integrity": "sha512-TiVQhuU0pbhIZAUZacbPHMyzrIdiH+lnx+PMY/Pu/b93dJrq3wdZwzUJ0TPpvNxaqbHsxJvQZW3/h/beLiKq7Q==",
|
"integrity": "sha512-DlZF2/MhLlatDdlrIy3CUCpfdbLrKx+3SMjVo+WyHnPpwzkc/M3vwAHw4OVJf7DMvO+4vfRqSCMc/E9I1auN0g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "^3.975.1",
|
"@aws-sdk/core": "^3.975.3",
|
||||||
"@aws-sdk/types": "^3.974.0",
|
"@aws-sdk/types": "^3.974.2",
|
||||||
"@smithy/core": "^3.29.2",
|
"@smithy/core": "^3.29.4",
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -321,18 +321,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-sso": {
|
"node_modules/@aws-sdk/credential-provider-sso": {
|
||||||
"version": "3.973.1",
|
"version": "3.973.3",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.1.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.3.tgz",
|
||||||
"integrity": "sha512-3foTZUJ4821Ij60X7K3NJroygiZLnbBmarN+T//O2cjkISan90zElN3NBmgSlDrTQ7Gs6z/yO8V7h60QNcDZHQ==",
|
"integrity": "sha512-hmdDHoy2G5Es2e8IgelNMYUuSQI6uCIAKZMJ2u2PdKDhxvbk1uWD/g4+R7R5c/tJfKEB1+KjjWiaoCr/S+ZTiQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "^3.975.1",
|
"@aws-sdk/core": "^3.975.3",
|
||||||
"@aws-sdk/nested-clients": "^3.997.31",
|
"@aws-sdk/nested-clients": "^3.997.33",
|
||||||
"@aws-sdk/token-providers": "3.1083.0",
|
"@aws-sdk/token-providers": "3.1088.0",
|
||||||
"@aws-sdk/types": "^3.974.0",
|
"@aws-sdk/types": "^3.974.2",
|
||||||
"@smithy/core": "^3.29.2",
|
"@smithy/core": "^3.29.4",
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -340,17 +340,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": {
|
"node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": {
|
||||||
"version": "3.1083.0",
|
"version": "3.1088.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1083.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1088.0.tgz",
|
||||||
"integrity": "sha512-s0woKnxuHrExLc5L2ArIH5BMkbonHPtt+5hSBM8oknp9M6QTuUmmAmJ2E0EdzCGONrO+8+ADPqvv6UX0nNcc7A==",
|
"integrity": "sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "^3.975.1",
|
"@aws-sdk/core": "^3.975.3",
|
||||||
"@aws-sdk/nested-clients": "^3.997.31",
|
"@aws-sdk/nested-clients": "^3.997.33",
|
||||||
"@aws-sdk/types": "^3.974.0",
|
"@aws-sdk/types": "^3.974.2",
|
||||||
"@smithy/core": "^3.29.2",
|
"@smithy/core": "^3.29.4",
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -358,17 +358,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
||||||
"version": "3.972.63",
|
"version": "3.972.65",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.63.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.65.tgz",
|
||||||
"integrity": "sha512-8qZLFhM69eKcS37m459ctPR05Qimycm/74OPVioe6wNZabMT54GYhwBju0+J656RkMasNSawWQu+c8CmBe3TUQ==",
|
"integrity": "sha512-gHQb/Kt0chjk/JQDa/GJDqmAvEuVn8n7z10wK2h0LFM9TUDRkohgOO4aEF+s2sBLM0br7Cl5W6P7phgjrrJvLQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "^3.975.1",
|
"@aws-sdk/core": "^3.975.3",
|
||||||
"@aws-sdk/nested-clients": "^3.997.31",
|
"@aws-sdk/nested-clients": "^3.997.33",
|
||||||
"@aws-sdk/types": "^3.974.0",
|
"@aws-sdk/types": "^3.974.2",
|
||||||
"@smithy/core": "^3.29.2",
|
"@smithy/core": "^3.29.4",
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -376,15 +376,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/eventstream-handler-node": {
|
"node_modules/@aws-sdk/eventstream-handler-node": {
|
||||||
"version": "3.972.26",
|
"version": "3.972.29",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.26.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.29.tgz",
|
||||||
"integrity": "sha512-RE1fu7Nn05vG0EUJM+8Sde2GFecC658WGaC/asPzLF6K4x3H5ZaDBcQtHRE67Gdgb1VZpyUUliYejHFK1qt0Uw==",
|
"integrity": "sha512-t3tKQRTVXsI2QNPE3CaNjHl0wRO9Xi3acZkAyti2RQsiFmZ9Gi0kArX2ighlRJ1BtDVuul413gThAgzyTfgmWA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/types": "^3.974.0",
|
"@aws-sdk/types": "^3.974.2",
|
||||||
"@smithy/core": "^3.29.2",
|
"@smithy/core": "^3.29.4",
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -392,15 +392,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/middleware-eventstream": {
|
"node_modules/@aws-sdk/middleware-eventstream": {
|
||||||
"version": "3.972.22",
|
"version": "3.972.24",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.22.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.24.tgz",
|
||||||
"integrity": "sha512-jtkgmhevnpzC1WeS+Y/sgymYbaQ6qg7pVOUl5cUT/8MiLptqrtnXQlNV80m+j2WIx5MIL7kVHIZNxxcK2tfUEQ==",
|
"integrity": "sha512-oykin4mDWxNOuYQ7SF1cHzgYeuFEkF4cdRwgvjFFbIklkx09qIFBiOgsORafG9sXZFO3TayMmQuAQYgADXhI8w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/types": "^3.974.0",
|
"@aws-sdk/types": "^3.974.2",
|
||||||
"@smithy/core": "^3.29.2",
|
"@smithy/core": "^3.29.4",
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -408,18 +408,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/middleware-websocket": {
|
"node_modules/@aws-sdk/middleware-websocket": {
|
||||||
"version": "3.972.39",
|
"version": "3.972.41",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.39.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.41.tgz",
|
||||||
"integrity": "sha512-CS1spxRSezmTmI3PD+3Xrnp6KryTSEz0EefA8u6uGd0s2I0uXseWHALDI/03Wi0IUczXNWo2QrZEaHDuJNby/Q==",
|
"integrity": "sha512-LSbGvvYmjc4Br9BPYI2dTLnIclmrSiQbahkP4D6nRGVEv4qsCZ8csVuKBPVEEFCVD+EEngGh8ROls6XpumtwMg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "^3.975.1",
|
"@aws-sdk/core": "^3.975.3",
|
||||||
"@aws-sdk/types": "^3.974.0",
|
"@aws-sdk/types": "^3.974.2",
|
||||||
"@smithy/core": "^3.29.2",
|
"@smithy/core": "^3.29.4",
|
||||||
"@smithy/fetch-http-handler": "^5.6.4",
|
"@smithy/fetch-http-handler": "^5.6.6",
|
||||||
"@smithy/signature-v4": "^5.6.3",
|
"@smithy/signature-v4": "^5.6.5",
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -427,19 +427,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/nested-clients": {
|
"node_modules/@aws-sdk/nested-clients": {
|
||||||
"version": "3.997.31",
|
"version": "3.997.33",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.31.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.33.tgz",
|
||||||
"integrity": "sha512-BDHTpwcsZHEBNEJzOg/B1BkFYJxAXY50dau/NyVWs3d51F0WgIUGSWZot/Os+N3KpDhXeaXnz37mWffAvduREw==",
|
"integrity": "sha512-dVZOroI/r3/ENvqNGgjMPul+jjlz9GddfVusgTXlVjfZj5isibOxecLkGQbRPp8XOuX+RAfjXLFgPkD1JS5xrw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "^3.975.1",
|
"@aws-sdk/core": "^3.975.3",
|
||||||
"@aws-sdk/signature-v4-multi-region": "^3.996.39",
|
"@aws-sdk/signature-v4-multi-region": "^3.996.41",
|
||||||
"@aws-sdk/types": "^3.974.0",
|
"@aws-sdk/types": "^3.974.2",
|
||||||
"@smithy/core": "^3.29.2",
|
"@smithy/core": "^3.29.4",
|
||||||
"@smithy/fetch-http-handler": "^5.6.4",
|
"@smithy/fetch-http-handler": "^5.6.6",
|
||||||
"@smithy/node-http-handler": "^4.9.4",
|
"@smithy/node-http-handler": "^4.9.6",
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -447,14 +447,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": {
|
"node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": {
|
||||||
"version": "4.9.4",
|
"version": "4.9.7",
|
||||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.7.tgz",
|
||||||
"integrity": "sha512-BNTop/fSOptmoVk8g+efwHCofFh37g70OWGAFES1TeAAJja1K5aAI8rTE26ETSc5k8IQuWY2kAIoPla01NgYrA==",
|
"integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@smithy/core": "^3.29.2",
|
"@smithy/core": "^3.29.5",
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -462,15 +462,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
||||||
"version": "3.996.39",
|
"version": "3.996.41",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.39.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.41.tgz",
|
||||||
"integrity": "sha512-8+srXqYIF8KYMLC4FxMLEM5Ek7kUNibJu1R4m8/fUhhNYIZZz26oGtKkCr8I/HiG2fFQxBvaGgQZT4/mqRCSnA==",
|
"integrity": "sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/types": "^3.974.0",
|
"@aws-sdk/types": "^3.974.2",
|
||||||
"@smithy/signature-v4": "^5.6.3",
|
"@smithy/signature-v4": "^5.6.5",
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -496,13 +496,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/types": {
|
"node_modules/@aws-sdk/types": {
|
||||||
"version": "3.974.0",
|
"version": "3.974.2",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz",
|
||||||
"integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==",
|
"integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -523,13 +523,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/xml-builder": {
|
"node_modules/@aws-sdk/xml-builder": {
|
||||||
"version": "3.972.34",
|
"version": "3.972.36",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.34.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.36.tgz",
|
||||||
"integrity": "sha512-wHhWL1y7sN3enBA8POrPpQM5jCcmu2ozyhbRei4c8OjVcEaEs6yLucLa/pla457ggS/ysuy7bosagz3HaJkZXA==",
|
"integrity": "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -984,13 +984,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-agent-core": {
|
"node_modules/@earendil-works/pi-agent-core": {
|
||||||
"version": "0.80.6",
|
"version": "0.80.10",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.6.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz",
|
||||||
"integrity": "sha512-Lvn89ko42h5ETUb6Z0Ku6ldskEqXaTdQBYvSa0+7bdG9V6rUEpXptv5e0OVZ1HDcvi8s6/2lGCQWsxKX+DFHNw==",
|
"integrity": "sha512-nwnOR3SuLYGRFfyQm8ri4Nj5VGVAvAM9GuqQd3u7BUQj0d6hmD2F8w7OHAAjThE3CuySIdM+v8E22QJG6/RfCg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-ai": "^0.80.6",
|
"@earendil-works/pi-ai": "^0.80.10",
|
||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"typebox": "1.1.38",
|
"typebox": "1.1.38",
|
||||||
"yaml": "2.9.0"
|
"yaml": "2.9.0"
|
||||||
@@ -1007,9 +1007,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-ai": {
|
"node_modules/@earendil-works/pi-ai": {
|
||||||
"version": "0.80.6",
|
"version": "0.80.10",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.6.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz",
|
||||||
"integrity": "sha512-7xfLk8sANBp+bpPEbjoOZTbPxsa+++b1JXAoSJsNa3vbs9AHHEclmvg54XLQcxH+fuwaeti/g2jeIfJ+mVYLpA==",
|
"integrity": "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1040,15 +1040,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-coding-agent": {
|
"node_modules/@earendil-works/pi-coding-agent": {
|
||||||
"version": "0.80.6",
|
"version": "0.80.10",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.6.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.10.tgz",
|
||||||
"integrity": "sha512-vcfD6tOk402isLl3Cm/qbn2O10TvgroMp1+/fEGM24ZdvETFCdOYv5VZ7m59EI5fPsjfSJh+CpQ5bhBrhfOg7g==",
|
"integrity": "sha512-aL4apbupCHiVLSXASXvRzH4Q2vmtfrDa+0s909CJuVu/GgGylbDzr7oyF1mPmip5E+VxYYxKWmph4hV04wUcQg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"hasShrinkwrap": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-agent-core": "^0.80.6",
|
"@earendil-works/pi-agent-core": "^0.80.10",
|
||||||
"@earendil-works/pi-ai": "^0.80.6",
|
"@earendil-works/pi-ai": "^0.80.10",
|
||||||
"@earendil-works/pi-tui": "^0.80.6",
|
"@earendil-works/pi-tui": "^0.80.10",
|
||||||
"@silvia-odwyer/photon-node": "0.3.4",
|
"@silvia-odwyer/photon-node": "0.3.4",
|
||||||
"chalk": "5.6.2",
|
"chalk": "5.6.2",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
@@ -1538,12 +1539,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": {
|
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": {
|
||||||
"version": "0.80.6",
|
"version": "0.80.10",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.6.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-ai": "^0.80.6",
|
"@earendil-works/pi-ai": "^0.80.10",
|
||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"typebox": "1.1.38",
|
"typebox": "1.1.38",
|
||||||
"yaml": "2.9.0"
|
"yaml": "2.9.0"
|
||||||
@@ -1553,8 +1554,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": {
|
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": {
|
||||||
"version": "0.80.6",
|
"version": "0.80.10",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.6.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1571,15 +1572,15 @@
|
|||||||
"typebox": "1.1.38"
|
"typebox": "1.1.38"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"pi-ai": "./dist/cli.js"
|
"pi-ai": "dist/cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.19.0"
|
"node": ">=22.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": {
|
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": {
|
||||||
"version": "0.80.6",
|
"version": "0.80.10",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.6.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.10.tgz",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2076,6 +2077,16 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
@@ -2894,6 +2905,13 @@
|
|||||||
"node": ">=22.19.0"
|
"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": {
|
"node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||||
@@ -5327,13 +5345,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@smithy/core": {
|
"node_modules/@smithy/core": {
|
||||||
"version": "3.29.2",
|
"version": "3.29.5",
|
||||||
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.2.tgz",
|
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.5.tgz",
|
||||||
"integrity": "sha512-DXUk6yU0C1Q1tYvJh1VCtl8QOBcSoZpKwjTPkxT6A4MUQYHvgeKGByL8mrEdxnvhdf9nq5GyzmRb5n/vPgu3Lw==",
|
"integrity": "sha512-i0dk2t5B+CwV/dcJdUHILYkOQF5lof8f44dFCfDWToGCxjT9YQ+CgHqTAvJxzc3+zqQwm2QtVoJ5IqiNar/CnQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -5341,14 +5359,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@smithy/credential-provider-imds": {
|
"node_modules/@smithy/credential-provider-imds": {
|
||||||
"version": "4.4.7",
|
"version": "4.4.10",
|
||||||
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.10.tgz",
|
||||||
"integrity": "sha512-UEMLOoA0Fl4uYBxh6l0uN0H6EJe/A89OGeDNTteQeXpJ20BcpfIr4wlCY9pel1jEAUHAxaYwuqrYlrKdXE1GKQ==",
|
"integrity": "sha512-MJenAe4OKRZUo1LdYYFDCsSHxaHvInIU/z52GsheO9vl1/VSySVCr0zkyKD6TFiGkSUaWGxvKZ/70OvgUZR5HQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@smithy/core": "^3.29.2",
|
"@smithy/core": "^3.29.5",
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -5356,14 +5374,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@smithy/fetch-http-handler": {
|
"node_modules/@smithy/fetch-http-handler": {
|
||||||
"version": "5.6.4",
|
"version": "5.6.7",
|
||||||
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.7.tgz",
|
||||||
"integrity": "sha512-psnst7NZWdAEvJvyW8YZEE7xNVMyLrQFfHtyrVFrxNyy+dKWkQ+rqC6oI5ZhxThpUy9RSfEshgm34zqbOxzsRw==",
|
"integrity": "sha512-3zpg8yqqyXzoK2TsRDdkqVOj2RDBFfLXwCczOZ5c7TWB4eiaebfSCsbMjDPYB3PJ9ihV62QaeadZ+wLadZtNGA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@smithy/core": "^3.29.2",
|
"@smithy/core": "^3.29.5",
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -5399,14 +5417,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@smithy/signature-v4": {
|
"node_modules/@smithy/signature-v4": {
|
||||||
"version": "5.6.3",
|
"version": "5.6.6",
|
||||||
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.6.tgz",
|
||||||
"integrity": "sha512-8qVKKzqh7naF27ePmx0SkUfnGP/wBI9dyaeAmhHvopnbIlItUAmB/e6PkPCU3rRb2v9BY8D4EZXSoydSibatvw==",
|
"integrity": "sha512-efP6DN3UTFrzIsGO42/xcabv8jU7+9nwEdphFUH7yL0k010ERyAWaO41KFQIDLcFZLZ8xzIQr4wplFxNzslSGQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@smithy/core": "^3.29.2",
|
"@smithy/core": "^3.29.5",
|
||||||
"@smithy/types": "^4.16.0",
|
"@smithy/types": "^4.16.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -5414,9 +5432,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@smithy/types": {
|
"node_modules/@smithy/types": {
|
||||||
"version": "4.16.0",
|
"version": "4.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz",
|
||||||
"integrity": "sha512-aVUabzlBBmY0PfvVgLKQSOGFIL5/7R54JE3uD9a5Ay/jSED61SkuAcCYENNXJzYUvJ1NPrWO0P+rAXHCkbBUKw==",
|
"integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+7
-7
@@ -82,9 +82,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@changesets/cli": "^2.31.0",
|
"@changesets/cli": "^2.31.0",
|
||||||
"@earendil-works/pi-agent-core": "^0.80.6",
|
"@earendil-works/pi-agent-core": "^0.80.8",
|
||||||
"@earendil-works/pi-ai": "^0.80.6",
|
"@earendil-works/pi-ai": "^0.80.8",
|
||||||
"@earendil-works/pi-coding-agent": "^0.80.6",
|
"@earendil-works/pi-coding-agent": "^0.80.8",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22.19.0"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -113,9 +113,9 @@
|
|||||||
"homepage": "https://pi-web.dev/",
|
"homepage": "https://pi-web.dev/",
|
||||||
"packageManager": "[email protected]",
|
"packageManager": "[email protected]",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@earendil-works/pi-agent-core": ">=0.80.0 <1",
|
"@earendil-works/pi-agent-core": ">=0.80.8 <0.81",
|
||||||
"@earendil-works/pi-ai": ">=0.80.0 <1",
|
"@earendil-works/pi-ai": ">=0.80.8 <0.81",
|
||||||
"@earendil-works/pi-coding-agent": ">=0.80.0 <1"
|
"@earendil-works/pi-coding-agent": ">=0.80.8 <0.81"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"pi-package",
|
"pi-package",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
doctorExitCode,
|
doctorExitCode,
|
||||||
isCliEntrypoint,
|
isCliEntrypoint,
|
||||||
launchdRuntimeDetails,
|
launchdRuntimeDetails,
|
||||||
|
nodeVersionCheck,
|
||||||
regularFileExists,
|
regularFileExists,
|
||||||
serviceBackendForPlatform,
|
serviceBackendForPlatform,
|
||||||
} from "./cli.js";
|
} 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", () => {
|
describe("agentCommandForChecks", () => {
|
||||||
it("reads the configured agent command for doctor checks", () => {
|
it("reads the configured agent command for doctor checks", () => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "pi-web-cli-test-"));
|
const dir = mkdtempSync(join(tmpdir(), "pi-web-cli-test-"));
|
||||||
|
|||||||
+10
-6
@@ -15,6 +15,7 @@ import {
|
|||||||
type NativeServiceInstallFailure,
|
type NativeServiceInstallFailure,
|
||||||
} from "./nativeServices/serviceInstall.js";
|
} from "./nativeServices/serviceInstall.js";
|
||||||
import {
|
import {
|
||||||
|
minimumSupportedNodeVersion,
|
||||||
nativeServiceManagerRefs,
|
nativeServiceManagerRefs,
|
||||||
productionNativeServiceIds,
|
productionNativeServiceIds,
|
||||||
type NativeServiceBackend,
|
type NativeServiceBackend,
|
||||||
@@ -772,11 +773,14 @@ export function commandWithVersionCheck(command: string): string {
|
|||||||
return `${found} && (${commandWord} --version 2>&1 || true)`;
|
return `${found} && (${commandWord} --version 2>&1 || true)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function nodeVersionCheck(): string {
|
export function nodeVersionCheck(): string {
|
||||||
return [
|
return nativeServicePrerequisiteShellCheck(detectServiceShell().name, {
|
||||||
commandCheck("node"),
|
id: "caller.node",
|
||||||
"node -e \"const major = Number(process.versions.node.split('.')[0]); console.log(process.version); process.exit(major >= 22 ? 0 : 1);\"",
|
kind: "node-version",
|
||||||
].join(" && ");
|
command: "node",
|
||||||
|
minimumVersion: minimumSupportedNodeVersion,
|
||||||
|
description: `node >= ${minimumSupportedNodeVersion}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function agentCommandForChecks(env: NodeJS.ProcessEnv = process.env): string {
|
export function agentCommandForChecks(env: NodeJS.ProcessEnv = process.env): string {
|
||||||
@@ -787,7 +791,7 @@ function generalDoctorChecks(): Check[] {
|
|||||||
const shell = serviceShellLabel();
|
const shell = serviceShellLabel();
|
||||||
const agentCommand = agentCommandForChecks();
|
const agentCommand = agentCommandForChecks();
|
||||||
return [
|
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 npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
|
||||||
[`Caller login ${shell} can find ${agentCommand}`, serviceShellCommand(commandWithVersionCheck(agentCommand))],
|
[`Caller login ${shell} can find ${agentCommand}`, serviceShellCommand(commandWithVersionCheck(agentCommand))],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ export const sessionsApi = {
|
|||||||
return request(`${machinePrefix(options?.machineId)}/auth/providers${query === "" ? "" : `?${query}`}`, parseAuthProvidersResponse);
|
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 }) }),
|
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 }) }),
|
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 }) }),
|
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),
|
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.detachParent(session, machineId)),
|
||||||
ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })),
|
ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })),
|
||||||
ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)),
|
ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.startInteractiveApiKeyLogin("amazon-bedrock", machineId)),
|
||||||
ignoreParseFailure(sessionsApi.logoutProvider("openai", machineId)),
|
ignoreParseFailure(sessionsApi.logoutProvider("openai", machineId)),
|
||||||
ignoreParseFailure(sessionsApi.startOAuthLogin("openai", machineId)),
|
ignoreParseFailure(sessionsApi.startOAuthLogin("openai", machineId)),
|
||||||
ignoreParseFailure(sessionsApi.oauthFlow("flow 1", machineId)),
|
ignoreParseFailure(sessionsApi.oauthFlow("flow 1", machineId)),
|
||||||
|
|||||||
@@ -1,8 +1,59 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
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", () => {
|
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", () => {
|
it("parses PI WEB config responses", () => {
|
||||||
expect(parsePiWebConfigResponse({
|
expect(parsePiWebConfigResponse({
|
||||||
path: "/tmp/config.json",
|
path: "/tmp/config.json",
|
||||||
|
|||||||
@@ -369,7 +369,15 @@ function parseAuthProviderStatus(value: unknown): AuthProviderStatus {
|
|||||||
|
|
||||||
function parseAuthProviderOption(value: unknown): AuthProviderOption {
|
function parseAuthProviderOption(value: unknown): AuthProviderOption {
|
||||||
const record = requireRecord(value);
|
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 {
|
export function parseAuthProvidersResponse(value: unknown): AuthProvidersResponse {
|
||||||
@@ -392,6 +400,7 @@ export function parseOAuthFlowState(value: unknown): OAuthFlowState {
|
|||||||
...optionalField("auth", optionalOAuthAuth(record["auth"])),
|
...optionalField("auth", optionalOAuthAuth(record["auth"])),
|
||||||
...optionalField("prompt", optionalOAuthPrompt(record["prompt"])),
|
...optionalField("prompt", optionalOAuthPrompt(record["prompt"])),
|
||||||
...optionalField("select", optionalOAuthSelect(record["select"])),
|
...optionalField("select", optionalOAuthSelect(record["select"])),
|
||||||
|
...optionalField("info", optionalOAuthInfo(record["info"])),
|
||||||
};
|
};
|
||||||
return flow;
|
return flow;
|
||||||
}
|
}
|
||||||
@@ -404,7 +413,21 @@ function parseOAuthFlowStatus(value: unknown): OAuthFlowState["status"] {
|
|||||||
function optionalOAuthAuth(value: unknown): OAuthFlowState["auth"] | undefined {
|
function optionalOAuthAuth(value: unknown): OAuthFlowState["auth"] | undefined {
|
||||||
if (value === undefined) return undefined;
|
if (value === undefined) return undefined;
|
||||||
const record = requireRecord(value);
|
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 {
|
function optionalOAuthPrompt(value: unknown): OAuthFlowState["prompt"] | undefined {
|
||||||
@@ -412,7 +435,20 @@ function optionalOAuthPrompt(value: unknown): OAuthFlowState["prompt"] | undefin
|
|||||||
const record = requireRecord(value);
|
const record = requireRecord(value);
|
||||||
const kind = requireString(record, "kind");
|
const kind = requireString(record, "kind");
|
||||||
if (kind !== "prompt" && kind !== "manual") throw new Error("Invalid OAuth prompt 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 {
|
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"]) };
|
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 {
|
function optionalContextUsage(value: unknown): Pick<SessionStatus, "contextUsage"> | object {
|
||||||
if (value === undefined) return {};
|
if (value === undefined) return {};
|
||||||
const record = requireRecord(value);
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { LitElement, css, html } from "lit";
|
import { LitElement, css, html } from "lit";
|
||||||
import { customElement, property, query } from "lit/decorators.js";
|
import { customElement, property, query } from "lit/decorators.js";
|
||||||
import type { AuthDialogState } from "../appState";
|
import type { AuthDialogState } from "../appState";
|
||||||
import type { AuthProviderOption } from "../api";
|
import type { AuthProviderOption, OAuthFlowState } from "../api";
|
||||||
import { commandPickerStyles } from "./shared";
|
import { commandPickerStyles } from "./shared";
|
||||||
|
|
||||||
@customElement("auth-dialog")
|
@customElement("auth-dialog")
|
||||||
@@ -42,7 +42,7 @@ export class AuthDialog extends LitElement {
|
|||||||
private dialogTitle(state: AuthDialogState): string {
|
private dialogTitle(state: AuthDialogState): string {
|
||||||
switch (state.step) {
|
switch (state.step) {
|
||||||
case "method": return "Configure provider authentication";
|
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 "apiKey": return `API key for ${state.provider.name}`;
|
||||||
case "oauth": return `Login to ${state.flow.providerName}`;
|
case "oauth": return `Login to ${state.flow.providerName}`;
|
||||||
case "logout": return "Remove stored provider authentication";
|
case "logout": return "Remove stored provider authentication";
|
||||||
@@ -54,7 +54,7 @@ export class AuthDialog extends LitElement {
|
|||||||
case "method": return html`
|
case "method": return html`
|
||||||
<div class="options">
|
<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?.("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>
|
</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>`;
|
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 flow = state.flow;
|
||||||
const prompt = flow.prompt;
|
const prompt = flow.prompt;
|
||||||
const select = flow.select;
|
const select = flow.select;
|
||||||
|
const promptInputType = oauthPromptInputType(prompt?.promptType);
|
||||||
return html`
|
return html`
|
||||||
<div class="form">
|
<div class="form">
|
||||||
${flow.auth !== undefined ? html`
|
${flow.auth !== undefined ? html`
|
||||||
<p>Open this authorization link:</p>
|
<p>Open this authorization link:</p>
|
||||||
<p><a href=${flow.auth.url} target="_blank" rel="noreferrer">${flow.auth.url}</a></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>`}
|
` : 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.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`
|
${prompt !== undefined ? html`
|
||||||
<label>${prompt.message}</label>
|
<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>
|
<div class="actions"><button @click=${() => { this.onOAuthCancel?.(); }}>Cancel</button><button class="primary" ?disabled=${state.responding === true} @click=${() => { this.onOAuthRespond?.(); }}>Submit</button></div>
|
||||||
` : null}
|
` : null}
|
||||||
${select !== undefined ? html`
|
${select !== undefined ? html`
|
||||||
<p>${select.message}</p>
|
<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}
|
` : null}
|
||||||
${state.error !== undefined && state.error !== "" ? html`<div class="error-text">${state.error}</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}
|
${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); }
|
.warning { color: var(--pi-warning); }
|
||||||
.error-text { color: var(--pi-danger); }
|
.error-text { color: var(--pi-danger); }
|
||||||
.progress { margin: 0; padding-left: 18px; color: var(--pi-muted); }
|
.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 { 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; }
|
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 {
|
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 {
|
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 { api as defaultApi, type AuthProviderOption, type OAuthFlowState, type SessionInfo, type SessionStatus } from "../api";
|
||||||
import { initialAppState, type AppState } from "../appState";
|
import { initialAppState, type AppState } from "../appState";
|
||||||
import { AuthController, parseAuthSlashCommand } from "./authController";
|
import { AuthController, parseAuthSlashCommand } from "./authController";
|
||||||
@@ -31,6 +31,34 @@ describe("AuthController", () => {
|
|||||||
expect(getState().authDialog).toMatchObject({ step: "apiKey", provider: { id: "anthropic", authType: "api_key" } });
|
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 () => {
|
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 flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
|
||||||
const { controller, getState } = createController(
|
const { controller, getState } = createController(
|
||||||
@@ -43,6 +71,26 @@ describe("AuthController", () => {
|
|||||||
expect(getState().authDialog).toMatchObject({ step: "oauth", inputValue: "https://callback", responding: true });
|
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 () => {
|
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 flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
|
||||||
const { controller, getState } = createController(
|
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 () => {
|
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 flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
|
||||||
const cancelCalls: { flowId: string; machineId: string | undefined }[] = [];
|
const cancelCalls: { flowId: string; machineId: string | undefined }[] = [];
|
||||||
@@ -226,6 +376,13 @@ async function flushMicrotasks(): Promise<void> {
|
|||||||
await Promise.resolve();
|
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"]> {
|
function remoteMachine(id: string): NonNullable<AppState["selectedMachine"]> {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { api as defaultApi, type AuthProviderOption, type AuthType, type OAuthFlowState, type SessionStatus } from "../api";
|
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";
|
import { selectedMachineId, type GetState, type SetState } from "./types";
|
||||||
|
|
||||||
|
type OAuthDialogState = Extract<AuthDialogState, { step: "oauth" }>;
|
||||||
|
|
||||||
export interface AuthControllerDependencies {
|
export interface AuthControllerDependencies {
|
||||||
api?: typeof defaultApi;
|
api?: typeof defaultApi;
|
||||||
pollIntervalMs?: number;
|
pollIntervalMs?: number;
|
||||||
@@ -9,6 +12,8 @@ export interface AuthControllerDependencies {
|
|||||||
export class AuthController {
|
export class AuthController {
|
||||||
private readonly api: typeof defaultApi;
|
private readonly api: typeof defaultApi;
|
||||||
private readonly pollIntervalMs: number;
|
private readonly pollIntervalMs: number;
|
||||||
|
private oauthOperationGeneration = 0;
|
||||||
|
private pollGeneration = 0;
|
||||||
private pollTimer: number | undefined;
|
private pollTimer: number | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -22,6 +27,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
|
this.oauthOperationGeneration += 1;
|
||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +61,7 @@ export class AuthController {
|
|||||||
if (dialog?.step !== "providers") return;
|
if (dialog?.step !== "providers") return;
|
||||||
const provider = dialog.providers.find((candidate) => candidate.id === providerId && (authType === undefined || candidate.authType === authType));
|
const provider = dialog.providers.find((candidate) => candidate.id === providerId && (authType === undefined || candidate.authType === authType));
|
||||||
if (provider === undefined) return;
|
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: "" } });
|
else this.setState({ authDialog: { step: "apiKey", provider, value: "" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,15 +134,22 @@ export class AuthController {
|
|||||||
if (dialog?.step !== "oauth") return;
|
if (dialog?.step !== "oauth") return;
|
||||||
const request = dialog.flow.prompt ?? dialog.flow.select;
|
const request = dialog.flow.prompt ?? dialog.flow.select;
|
||||||
if (request === undefined) return;
|
if (request === undefined) return;
|
||||||
|
const operationGeneration = this.oauthOperationGeneration;
|
||||||
|
const flowId = dialog.flow.flowId;
|
||||||
|
const requestId = request.requestId;
|
||||||
const responseValue = value ?? dialog.inputValue ?? "";
|
const responseValue = value ?? dialog.inputValue ?? "";
|
||||||
const clean = { ...dialog };
|
const clean = { ...dialog };
|
||||||
delete clean.error;
|
delete clean.error;
|
||||||
this.setState({ authDialog: { ...clean, responding: true } });
|
this.setState({ authDialog: { ...clean, responding: true } });
|
||||||
try {
|
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);
|
this.updateOAuthFlow(flow);
|
||||||
} catch (error) {
|
} 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();
|
this.closeDialog();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.stopPolling();
|
const flowId = dialog.flow.flowId;
|
||||||
try {
|
const machineId = selectedMachineId(this.getState());
|
||||||
await this.api.cancelOAuthFlow(dialog.flow.flowId, selectedMachineId(this.getState()));
|
|
||||||
} catch {
|
|
||||||
// Best-effort cancel. The dialog closes either way.
|
|
||||||
}
|
|
||||||
this.closeDialog();
|
this.closeDialog();
|
||||||
|
try {
|
||||||
|
await this.api.cancelOAuthFlow(flowId, machineId);
|
||||||
|
} catch {
|
||||||
|
// Best-effort cancel. The dialog is already closed either way.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
closeDialog(): void {
|
closeDialog(): void {
|
||||||
|
this.oauthOperationGeneration += 1;
|
||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
this.setState({ authDialog: undefined });
|
this.setState({ authDialog: undefined });
|
||||||
}
|
}
|
||||||
@@ -174,21 +189,27 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
const provider = exact[0];
|
const provider = exact[0];
|
||||||
if (provider === undefined) return;
|
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: "" } });
|
else this.setState({ authDialog: { step: "apiKey", provider, value: "" } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(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;
|
if (this.rejectRemoteOAuth("login", provider)) return;
|
||||||
|
const operationGeneration = ++this.oauthOperationGeneration;
|
||||||
|
this.stopPolling();
|
||||||
try {
|
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.updateOAuthFlow(flow);
|
||||||
this.startPolling(flow.flowId);
|
if (flow.status === "running") this.startPolling(flow.flowId);
|
||||||
} catch (error) {
|
} 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();
|
void this.refreshStatus();
|
||||||
return;
|
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 existing = this.getState().authDialog;
|
||||||
const previousInput = existing?.step === "oauth" && existing.flow.flowId === flow.flowId ? existing.inputValue ?? "" : "";
|
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 previousRequestId = existing?.step === "oauth" ? oauthRequestId(existing.flow) : undefined;
|
||||||
const newRequestId = flow.prompt?.requestId ?? flow.select?.requestId;
|
const newRequestId = oauthRequestId(flow);
|
||||||
const sameRequest = previousRequestId !== undefined && previousRequestId === newRequestId;
|
const sameRequest = previousRequestId !== undefined && previousRequestId === newRequestId;
|
||||||
const inputValue = sameRequest ? previousInput : "";
|
const inputValue = sameRequest ? previousInput : "";
|
||||||
const responding = sameRequest && existing?.step === "oauth" ? existing.responding === true : false;
|
const responding = sameRequest && existing?.step === "oauth" ? existing.responding === true : false;
|
||||||
@@ -220,29 +244,45 @@ export class AuthController {
|
|||||||
|
|
||||||
private startPolling(flowId: string): void {
|
private startPolling(flowId: string): void {
|
||||||
this.stopPolling();
|
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 {
|
private stopPolling(): void {
|
||||||
|
this.pollGeneration += 1;
|
||||||
if (this.pollTimer === undefined) return;
|
if (this.pollTimer === undefined) return;
|
||||||
window.clearInterval(this.pollTimer);
|
window.clearInterval(this.pollTimer);
|
||||||
this.pollTimer = undefined;
|
this.pollTimer = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async poll(flowId: string): Promise<void> {
|
private async poll(flowId: string, operationGeneration: number, pollGeneration: number): Promise<void> {
|
||||||
const dialog = this.getState().authDialog;
|
if (pollGeneration !== this.pollGeneration) return;
|
||||||
if (dialog?.step !== "oauth" || dialog.flow.flowId !== flowId) {
|
const dialog = this.currentOAuthDialog(operationGeneration, flowId);
|
||||||
|
if (dialog === undefined) {
|
||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const requestId = oauthRequestId(dialog.flow);
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
|
const current = this.currentOAuthDialog(operationGeneration, flowId);
|
||||||
|
if (pollGeneration !== this.pollGeneration || current === undefined || oauthRequestId(current.flow) !== requestId) return;
|
||||||
this.stopPolling();
|
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> {
|
private async refreshStatus(): Promise<void> {
|
||||||
const session = this.session();
|
const session = this.session();
|
||||||
if (session === undefined) return;
|
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 {
|
export function parseAuthSlashCommand(text: string): { command: "login" | "logout"; providerId?: string } | undefined {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
const match = /^\/(login|logout)(?:\s+(\S+))?\s*$/u.exec(trimmed);
|
const match = /^\/(login|logout)(?:\s+(\S+))?\s*$/u.exec(trimmed);
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ describe("production native service planning", () => {
|
|||||||
wants: [],
|
wants: [],
|
||||||
prerequisites: [
|
prerequisites: [
|
||||||
{ id: "sessiond.command.pi-web-sessiond", kind: "command-available", command: "pi-web-sessiond" },
|
{ 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"],
|
wants: ["sessiond"],
|
||||||
prerequisites: [
|
prerequisites: [
|
||||||
{ id: "web.command.pi-web-server", kind: "command-available", command: "pi-web-server" },
|
{ 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",
|
namedCommandFailure: "command not found",
|
||||||
},
|
},
|
||||||
prerequisites: [
|
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" },
|
{ 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" },
|
environment: { PI_WEB_CONFIG: "/tmp/config.json" },
|
||||||
workingDirectory: "/checkout with space",
|
workingDirectory: "/checkout with space",
|
||||||
prerequisites: [
|
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.command.npm", kind: "command-available", command: "npm" },
|
||||||
{ id: "sessiond.package-scripts", kind: "package-scripts", scripts: ["start:sessiond"] },
|
{ id: "sessiond.package-scripts", kind: "package-scripts", scripts: ["start:sessiond"] },
|
||||||
],
|
],
|
||||||
@@ -338,7 +338,7 @@ describe("development native service planning", () => {
|
|||||||
after: ["sessiond"],
|
after: ["sessiond"],
|
||||||
wants: ["sessiond"],
|
wants: ["sessiond"],
|
||||||
prerequisites: [
|
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.npm", kind: "command-available", command: "npm" },
|
||||||
{ id: "uiDev.command.bash", kind: "command-available", command: "bash" },
|
{ id: "uiDev.command.bash", kind: "command-available", command: "bash" },
|
||||||
{ id: "uiDev.package-scripts", kind: "package-scripts", scripts: ["dev:web", "dev:client"] },
|
{ id: "uiDev.package-scripts", kind: "package-scripts", scripts: ["dev:web", "dev:client"] },
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export const minimumSupportedNodeVersion = "22.19.0";
|
||||||
|
|
||||||
export type NativeServiceBackendKind = "systemd" | "launchd";
|
export type NativeServiceBackendKind = "systemd" | "launchd";
|
||||||
export type NativeServiceMode = "production" | "development";
|
export type NativeServiceMode = "production" | "development";
|
||||||
export type NativeServiceId = "sessiond" | "web" | "uiDev";
|
export type NativeServiceId = "sessiond" | "web" | "uiDev";
|
||||||
@@ -64,7 +66,7 @@ export type NativeServicePrerequisite =
|
|||||||
id: string;
|
id: string;
|
||||||
kind: "node-version";
|
kind: "node-version";
|
||||||
command: "node";
|
command: "node";
|
||||||
minimumMajor: number;
|
minimumVersion: string;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -571,8 +573,8 @@ function nodeRequirement(serviceId: NativeServiceId): NativeServicePrerequisite
|
|||||||
id: `${serviceId}.node`,
|
id: `${serviceId}.node`,
|
||||||
kind: "node-version",
|
kind: "node-version",
|
||||||
command: "node",
|
command: "node",
|
||||||
minimumMajor: 22,
|
minimumVersion: minimumSupportedNodeVersion,
|
||||||
description: "node >= 22 is available to the service shell",
|
description: `node >= ${minimumSupportedNodeVersion} is available to the service shell`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
LaunchdNativeServiceProbe,
|
LaunchdNativeServiceProbe,
|
||||||
@@ -5,6 +6,7 @@ import {
|
|||||||
SystemdNativeServiceProbe,
|
SystemdNativeServiceProbe,
|
||||||
launchdProbePlist,
|
launchdProbePlist,
|
||||||
nativeServicePrerequisiteShellCheck,
|
nativeServicePrerequisiteShellCheck,
|
||||||
|
nodeVersionCheckScript,
|
||||||
systemdRunArguments,
|
systemdRunArguments,
|
||||||
type LaunchdProbeFileSystem,
|
type LaunchdProbeFileSystem,
|
||||||
type ProbeCommandResult,
|
type ProbeCommandResult,
|
||||||
@@ -433,13 +435,26 @@ describe("probe service definitions", () => {
|
|||||||
id: "sessiond.node",
|
id: "sessiond.node",
|
||||||
kind: "node-version",
|
kind: "node-version",
|
||||||
command: "node",
|
command: "node",
|
||||||
minimumMajor: 22,
|
minimumVersion: "22.19.0",
|
||||||
description: "node >= 22",
|
description: "node >= 22.19.0",
|
||||||
});
|
});
|
||||||
expect(check).toContain("\"$pi_web_probe_executable\" '-e'");
|
expect(check).toContain("\"$pi_web_probe_executable\" '-e'");
|
||||||
|
expect(check).toContain("22.19.0");
|
||||||
expect(check).not.toContain("&& node -e");
|
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", () => {
|
it("requires bundled entrypoints to be readable regular files", () => {
|
||||||
const check = nativeServicePrerequisiteShellCheck("bash", {
|
const check = nativeServicePrerequisiteShellCheck("bash", {
|
||||||
id: "sessiond.entrypoint",
|
id: "sessiond.entrypoint",
|
||||||
|
|||||||
@@ -434,10 +434,8 @@ export function nativeServicePrerequisiteShellCheck(shell: NativeServiceShellNam
|
|||||||
switch (prerequisite.kind) {
|
switch (prerequisite.kind) {
|
||||||
case "command-available":
|
case "command-available":
|
||||||
return externalExecutableShellCheck(shell, prerequisite.command);
|
return externalExecutableShellCheck(shell, prerequisite.command);
|
||||||
case "node-version": {
|
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", nodeVersionCheckScript(prerequisite.minimumVersion)]);
|
||||||
return externalExecutableShellCheck(shell, "node", ["-e", script]);
|
|
||||||
}
|
|
||||||
case "readable-file": {
|
case "readable-file": {
|
||||||
const path = shellQuote(shell, prerequisite.path);
|
const path = shellQuote(shell, prerequisite.path);
|
||||||
return `test -f ${path} && test -r ${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(
|
function externalExecutableShellCheck(
|
||||||
shell: NativeServiceShellName,
|
shell: NativeServiceShellName,
|
||||||
command: string,
|
command: string,
|
||||||
@@ -518,7 +521,7 @@ function unsatisfiedDetail(prerequisite: NativeServicePrerequisite): string {
|
|||||||
case "command-available":
|
case "command-available":
|
||||||
return `${prerequisite.command} did not resolve to an external executable in the native service environment.`;
|
return `${prerequisite.command} did not resolve to an external executable in the native service environment.`;
|
||||||
case "node-version":
|
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":
|
case "readable-file":
|
||||||
return `${prerequisite.path} was not a readable regular file in the native service environment.`;
|
return `${prerequisite.path} was not a readable regular file in the native service environment.`;
|
||||||
case "package-scripts":
|
case "package-scripts":
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class FakeSocket extends EventEmitter implements RealtimeSocket {
|
|||||||
readonly OPEN = 1;
|
readonly OPEN = 1;
|
||||||
readyState = this.OPEN;
|
readyState = this.OPEN;
|
||||||
send = vi.fn();
|
send = vi.fn();
|
||||||
|
terminate = vi.fn();
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("SessionEventHub", () => {
|
describe("SessionEventHub", () => {
|
||||||
@@ -54,6 +55,30 @@ describe("SessionEventHub", () => {
|
|||||||
expect(removed.send).not.toHaveBeenCalled();
|
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", () => {
|
it("publishes global events only to global sockets", () => {
|
||||||
const hub = new SessionEventHub();
|
const hub = new SessionEventHub();
|
||||||
const globalSocket = new FakeSocket();
|
const globalSocket = new FakeSocket();
|
||||||
@@ -78,6 +103,29 @@ describe("SessionEventHub", () => {
|
|||||||
expect(sessionSocket.send).not.toHaveBeenCalled();
|
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", () => {
|
it("stamps a monotonically increasing per-session seq on published events", () => {
|
||||||
const hub = new SessionEventHub();
|
const hub = new SessionEventHub();
|
||||||
const socket = new FakeSocket();
|
const socket = new FakeSocket();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface RealtimeSocket {
|
|||||||
readonly OPEN: number;
|
readonly OPEN: number;
|
||||||
readyState: number;
|
readyState: number;
|
||||||
send(payload: string): void;
|
send(payload: string): void;
|
||||||
|
terminate(): void;
|
||||||
on(event: "close", listener: () => void): unknown;
|
on(event: "close", listener: () => void): unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,9 +35,7 @@ export class SessionEventHub {
|
|||||||
const seq = (this.seqBySession.get(sessionId) ?? 0) + 1;
|
const seq = (this.seqBySession.get(sessionId) ?? 0) + 1;
|
||||||
this.seqBySession.set(sessionId, seq);
|
this.seqBySession.set(sessionId, seq);
|
||||||
const payload = JSON.stringify({ ...projectBrowserSessionEvent(event), seq });
|
const payload = JSON.stringify({ ...projectBrowserSessionEvent(event), seq });
|
||||||
for (const socket of this.socketsBySession.get(sessionId) ?? []) {
|
this.sendToSockets(this.socketsBySession.get(sessionId), payload);
|
||||||
if (socket.readyState === socket.OPEN) socket.send(payload);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,8 +54,23 @@ export class SessionEventHub {
|
|||||||
|
|
||||||
publishRealtime(event: RealtimeEvent): void {
|
publishRealtime(event: RealtimeEvent): void {
|
||||||
const payload = JSON.stringify(event);
|
const payload = JSON.stringify(event);
|
||||||
for (const socket of this.globalSockets) {
|
this.sendToSockets(this.globalSockets, payload);
|
||||||
if (socket.readyState === socket.OPEN) socket.send(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.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,15 +36,15 @@ await app.register(fastifyWebsocket);
|
|||||||
|
|
||||||
await runSessionDaemonStartup({
|
await runSessionDaemonStartup({
|
||||||
logger: app.log,
|
logger: app.log,
|
||||||
createRuntime() {
|
async createRuntime() {
|
||||||
const eventHub = new SessionEventHub();
|
const eventHub = new SessionEventHub();
|
||||||
const workspaceActivity = new WorkspaceActivityService(eventHub);
|
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
|
const spawnTargets = config.spawnSessions
|
||||||
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
|
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
|
||||||
: undefined;
|
: undefined;
|
||||||
const sessions = new PiSessionService(eventHub, {
|
const sessions = new PiSessionService(eventHub, {
|
||||||
modelRegistry: auth.modelRegistry,
|
modelRuntime: auth.runtime,
|
||||||
agentDir: activeAgentProfile.dir,
|
agentDir: activeAgentProfile.dir,
|
||||||
workspaceActivity,
|
workspaceActivity,
|
||||||
logger: app.log,
|
logger: app.log,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface SessionDaemonStartupLogger {
|
|||||||
|
|
||||||
export interface SessionDaemonStartupSteps<Runtime> {
|
export interface SessionDaemonStartupSteps<Runtime> {
|
||||||
logger: SessionDaemonStartupLogger;
|
logger: SessionDaemonStartupLogger;
|
||||||
createRuntime(): Runtime;
|
createRuntime(): Runtime | Promise<Runtime>;
|
||||||
registerRoutes(runtime: Runtime): void;
|
registerRoutes(runtime: Runtime): void;
|
||||||
listen(runtime: Runtime): Promise<void>;
|
listen(runtime: Runtime): Promise<void>;
|
||||||
migrateArchive?: () => Promise<LegacySessionArchiveMigrationResult>;
|
migrateArchive?: () => Promise<LegacySessionArchiveMigrationResult>;
|
||||||
@@ -36,7 +36,7 @@ export async function runSessionDaemonStartup<Runtime>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtime = steps.createRuntime();
|
const runtime = await steps.createRuntime();
|
||||||
steps.registerRoutes(runtime);
|
steps.registerRoutes(runtime);
|
||||||
await steps.listen(runtime);
|
await steps.listen(runtime);
|
||||||
return runtime;
|
return runtime;
|
||||||
|
|||||||
@@ -1,52 +1,70 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { getLoginProviderOptions, getLogoutProviderOptions, isApiKeyLoginProvider, type AuthProviderModelRegistry } from "./authProviderOptions";
|
import { getLoginProviderOptions, getLogoutProviderOptions, type AuthProviderRuntime } from "./authProviderOptions";
|
||||||
|
|
||||||
function registry(): AuthProviderModelRegistry {
|
function runtime(configuredProviders: ReadonlySet<string> = new Set(["openai"])): AuthProviderRuntime {
|
||||||
const credentials = new Map<string, { type: "oauth" | "api_key" }>();
|
const credentials = [{ providerId: "openai", type: "api_key" as const }];
|
||||||
credentials.set("openai", { type: "api_key" });
|
// 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 {
|
return {
|
||||||
authStorage: {
|
getProviders: () => providers,
|
||||||
getOAuthProviders: () => [
|
listCredentials: () => Promise.resolve(credentials),
|
||||||
{ 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),
|
|
||||||
getProviderAuthStatus: (provider: string) => (provider === "openai" ? { configured: true, source: "stored" } : { configured: false }),
|
getProviderAuthStatus: (provider: string) => (provider === "openai" ? { configured: true, source: "stored" } : { configured: false }),
|
||||||
|
hasConfiguredAuth: (provider: string) => configuredProviders.has(provider),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("auth provider options", () => {
|
describe("auth provider options", () => {
|
||||||
it("keeps OAuth-only providers out of API key login options", () => {
|
it("offers each interactive login method reported by the backend", () => {
|
||||||
expect(isApiKeyLoginProvider("openai-codex", new Set(["openai-codex"]))).toBe(false);
|
const options = getLoginProviderOptions(runtime());
|
||||||
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());
|
|
||||||
expect(options).toEqual(expect.arrayContaining([
|
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: "oauth" }),
|
||||||
expect.objectContaining({ id: "anthropic", authType: "api_key" }),
|
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" }),
|
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-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", () => {
|
it("does not report a stored credential as configured when provider resolution is incomplete", async () => {
|
||||||
expect(getLogoutProviderOptions(registry())).toEqual([
|
const unresolvedRuntime = runtime(new Set());
|
||||||
expect.objectContaining({ id: "openai", authType: "api_key" }),
|
|
||||||
|
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" } }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,62 +1,78 @@
|
|||||||
import type { AuthProviderOption, AuthProviderStatus, AuthType } from "../../shared/apiTypes.js";
|
import type { AuthProviderOption, AuthProviderStatus, AuthType } from "../../shared/apiTypes.js";
|
||||||
|
|
||||||
const OAUTH_ONLY_PROVIDERS = new Set(["github-copilot", "openai-codex"]);
|
/** Minimal provider shape needed to enumerate login/logout options. */
|
||||||
|
interface AuthProviderInfo {
|
||||||
export interface AuthProviderModelRegistry {
|
id: string;
|
||||||
authStorage: {
|
name: string;
|
||||||
getOAuthProviders(): { id: string; name: string }[];
|
auth: { apiKey?: { login?: unknown }; oauth?: unknown };
|
||||||
list(): string[];
|
|
||||||
get(provider: string): { type: AuthType } | undefined;
|
|
||||||
};
|
|
||||||
getAll(): { provider: string }[];
|
|
||||||
getProviderDisplayName(provider: string): string;
|
|
||||||
getProviderAuthStatus(provider: string): AuthProviderStatus;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLoginProviderOptions(modelRegistry: AuthProviderModelRegistry, authType?: AuthType): AuthProviderOption[] {
|
/** Non-secret stored-credential metadata, keyed by provider id. */
|
||||||
const oauthProviders = modelRegistry.authStorage.getOAuthProviders();
|
interface AuthProviderCredentialInfo {
|
||||||
const oauthProviderIds = new Set(oauthProviders.map((provider) => provider.id));
|
providerId: string;
|
||||||
const options: AuthProviderOption[] = oauthProviders.map((provider) => ({
|
type: AuthType;
|
||||||
id: provider.id,
|
}
|
||||||
name: provider.name,
|
|
||||||
authType: "oauth",
|
|
||||||
status: modelRegistry.getProviderAuthStatus(provider.id),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const modelProviders = new Set(modelRegistry.getAll().map((model) => model.provider));
|
/**
|
||||||
for (const providerId of modelProviders) {
|
* Structural slice of the SDK `ModelRuntime` used to derive auth provider
|
||||||
if (!isApiKeyLoginProvider(providerId, oauthProviderIds)) continue;
|
* 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({
|
options.push({
|
||||||
id: providerId,
|
id: provider.id,
|
||||||
name: modelRegistry.getProviderDisplayName(providerId),
|
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",
|
authType: "api_key",
|
||||||
status: modelRegistry.getProviderAuthStatus(providerId),
|
status: truthfulProviderStatus(runtime, provider.id),
|
||||||
|
loginFlow: "interactive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return filterAndSort(options, authType);
|
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[] = [];
|
const options: AuthProviderOption[] = [];
|
||||||
for (const providerId of modelRegistry.authStorage.list()) {
|
for (const credential of await runtime.listCredentials()) {
|
||||||
const credential = modelRegistry.authStorage.get(providerId);
|
|
||||||
if (credential === undefined) continue;
|
|
||||||
options.push({
|
options.push({
|
||||||
id: providerId,
|
id: credential.providerId,
|
||||||
name: modelRegistry.getProviderDisplayName(providerId),
|
name: providerNames.get(credential.providerId) ?? credential.providerId,
|
||||||
authType: credential.type,
|
authType: credential.type,
|
||||||
status: modelRegistry.getProviderAuthStatus(providerId),
|
status: truthfulProviderStatus(runtime, credential.providerId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return filterAndSort(options);
|
return filterAndSort(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isApiKeyLoginProvider(providerId: string, oauthProviderIds: ReadonlySet<string>): boolean {
|
function truthfulProviderStatus(runtime: AuthProviderRuntime, providerId: string): AuthProviderStatus {
|
||||||
if (OAUTH_ONLY_PROVIDERS.has(providerId)) return false;
|
const reported = runtime.getProviderAuthStatus(providerId);
|
||||||
if (providerId === "anthropic") return true;
|
// ModelRuntime reports any stored entry as configured before checking whether
|
||||||
if (oauthProviderIds.has(providerId)) return false;
|
// the provider can resolve all required credential and ambient fields.
|
||||||
return true;
|
return reported.configured && !runtime.hasConfiguredAuth(providerId) ? { configured: false } : reported;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterAndSort(options: AuthProviderOption[], authType?: AuthType): AuthProviderOption[] {
|
function filterAndSort(options: AuthProviderOption[], authType?: AuthType): AuthProviderOption[] {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { AuthService } from "./authService.js";
|
|||||||
export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, prefix = ""): void {
|
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) => {
|
app.get<{ Querystring: { mode?: "login" | "logout"; authType?: "oauth" | "api_key" } }>(`${prefix}/auth/providers`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
return auth.authProviders(request.query.mode ?? "login", request.query.authType);
|
return await auth.authProviders(request.query.mode ?? "login", request.query.authType);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return reply.code(404).send({ error: error instanceof Error ? error.message : String(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) => {
|
app.post<{ Body: { providerId: string; key: string } }>(`${prefix}/auth/api-key`, async (request, reply) => {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(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) => {
|
app.post<{ Body: { providerId: string } }>(`${prefix}/auth/logout`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
return auth.logoutProvider(request.body.providerId);
|
return await auth.logoutProvider(request.body.providerId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(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) => {
|
app.post<{ Body: { providerId: string } }>(`${prefix}/auth/oauth`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
return auth.startOAuthLogin(request.body.providerId);
|
return await auth.startOAuthLogin(request.body.providerId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { OAuthFlowState } from "../../shared/apiTypes.js";
|
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";
|
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("AuthService", () => {
|
describe("AuthService", () => {
|
||||||
it("saves API keys and emits a global auth change", () => {
|
it("saves API keys and emits a global auth change after the runtime refreshes", async () => {
|
||||||
const { auth, authStorage, changes } = createAuthService();
|
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([{}]);
|
expect(changes).toEqual([{}]);
|
||||||
auth.dispose();
|
auth.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs out providers and emits the removed provider id", () => {
|
it("logs out providers and emits the removed provider id after the runtime refreshes", async () => {
|
||||||
const { auth, authStorage, changes } = createAuthService({ anthropic: { type: "api_key", key: "sk-test" } });
|
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" }]);
|
expect(changes).toEqual([{ removedProviderId: "anthropic" }]);
|
||||||
auth.dispose();
|
auth.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects blank API keys", () => {
|
it("persists an API key and attempts every listener when failure logging throws", async () => {
|
||||||
const { auth, changes } = createAuthService();
|
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([]);
|
expect(changes).toEqual([]);
|
||||||
auth.dispose();
|
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 () => {
|
it("stores credentials in the configured agent directory", async () => {
|
||||||
const agentDir = await tempAgentDir();
|
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");
|
await expect(readFile(join(agentDir, "auth.json"), "utf8")).resolves.toContain("sk-test");
|
||||||
auth.dispose();
|
auth.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refreshes auth state after OAuth login completes", () => {
|
it("reconciles cancellation after ModelRuntime persists OAuth but before its refresh completes", async () => {
|
||||||
const authStorage = AuthStorage.inMemory();
|
const { auth, runtime, credentials, changes } = await createAuthService();
|
||||||
const modelRegistry = ModelRegistry.create(authStorage);
|
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 authFlows = new CapturingOAuthLoginFlowService();
|
||||||
const auth = new AuthService({ modelRegistry, authFlows });
|
const auth = await AuthService.create({ runtime, authFlows });
|
||||||
const changes: AuthChange[] = [];
|
const changes: AuthChange[] = [];
|
||||||
auth.subscribe((change) => { changes.push(change); });
|
auth.subscribe((change) => { changes.push(change); });
|
||||||
const reload = vi.spyOn(authStorage, "reload");
|
const refresh = vi.spyOn(runtime, "refresh");
|
||||||
const refresh = vi.spyOn(modelRegistry, "refresh");
|
const provider = runtime.getProviders().find((option) => option.id === "anthropic" && option.auth.oauth !== undefined);
|
||||||
const provider = authStorage.getOAuthProviders().find((option) => option.id === "anthropic");
|
|
||||||
if (provider === undefined) throw new Error("Expected built-in OAuth provider");
|
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);
|
const startOptions = authFlows.startCalls.at(0);
|
||||||
if (startOptions === undefined) throw new Error("Expected OAuth flow to start");
|
if (startOptions === undefined) throw new Error("Expected OAuth flow to start");
|
||||||
expect(startOptions.providerId).toBe(provider.id);
|
expect(startOptions.providerId).toBe(provider.id);
|
||||||
expect(startOptions.providerName).toBe(provider.name);
|
expect(startOptions.providerName).toBe(provider.name);
|
||||||
expect(startOptions.authStorage).toBe(authStorage);
|
expect(startOptions.runtime).toBe(runtime);
|
||||||
expect(changes).toEqual([]);
|
expect(changes).toEqual([]);
|
||||||
|
|
||||||
reload.mockClear();
|
|
||||||
refresh.mockClear();
|
refresh.mockClear();
|
||||||
if (startOptions.onComplete === undefined) throw new Error("Expected OAuth completion callback");
|
if (startOptions.onComplete === undefined) throw new Error("Expected OAuth completion callback");
|
||||||
startOptions.onComplete();
|
await startOptions.onComplete();
|
||||||
|
|
||||||
expect(reload).toHaveBeenCalledOnce();
|
|
||||||
expect(refresh).toHaveBeenCalledOnce();
|
|
||||||
expect(changes).toEqual([{}]);
|
expect(changes).toEqual([{}]);
|
||||||
|
|
||||||
|
expect(refresh).not.toHaveBeenCalled();
|
||||||
auth.dispose();
|
auth.dispose();
|
||||||
expect(authFlows.disposed).toBe(true);
|
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] = {}) {
|
async function createAuthService(seed: Record<string, Credential> = {}, logger?: AuthServiceLogger) {
|
||||||
const authStorage = AuthStorage.inMemory(data);
|
const credentials = new InMemoryCredentialStore();
|
||||||
const modelRegistry = ModelRegistry.create(authStorage);
|
for (const [providerId, credential] of Object.entries(seed)) {
|
||||||
const auth = new AuthService({ modelRegistry });
|
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[] = [];
|
const changes: AuthChange[] = [];
|
||||||
auth.subscribe((change) => { changes.push(change); });
|
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> {
|
async function tempAgentDir(): Promise<string> {
|
||||||
@@ -101,6 +508,28 @@ async function tempAgentDir(): Promise<string> {
|
|||||||
return dir;
|
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 {
|
class CapturingOAuthLoginFlowService extends OAuthLoginFlowService {
|
||||||
readonly startCalls: Parameters<OAuthLoginFlowService["start"]>[0][] = [];
|
readonly startCalls: Parameters<OAuthLoginFlowService["start"]>[0][] = [];
|
||||||
disposed = false;
|
disposed = false;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { join } from "node:path";
|
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 type { AuthProvidersResponse, AuthType, OAuthFlowState } from "../../shared/apiTypes.js";
|
||||||
import { getLoginProviderOptions, getLogoutProviderOptions } from "./authProviderOptions.js";
|
import { getLoginProviderOptions, getLogoutProviderOptions } from "./authProviderOptions.js";
|
||||||
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
|
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
|
||||||
@@ -8,28 +9,53 @@ export interface AuthChange {
|
|||||||
removedProviderId?: string;
|
removedProviderId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthChangeListener = (change: AuthChange) => void;
|
type AuthChangeListener = (change: AuthChange) => void | Promise<void>;
|
||||||
type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
|
|
||||||
|
|
||||||
export interface AuthServiceDependencies {
|
export interface AuthServiceDependencies {
|
||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
modelRegistry?: ModelRegistryInstance;
|
runtime?: ModelRuntime;
|
||||||
authFlows?: OAuthLoginFlowService;
|
authFlows?: OAuthLoginFlowService;
|
||||||
|
logger?: AuthServiceLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createModelRegistryForAgentDir(agentDir: string): ModelRegistryInstance {
|
/** Minimal structured-logging seam for non-fatal auth propagation failures. */
|
||||||
const authStorage = AuthStorage.create(join(agentDir, "auth.json"));
|
export interface AuthServiceLogger {
|
||||||
return ModelRegistry.create(authStorage, join(agentDir, "models.json"));
|
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 {
|
export class AuthService {
|
||||||
readonly modelRegistry: ModelRegistryInstance;
|
readonly runtime: ModelRuntime;
|
||||||
private readonly authFlows: OAuthLoginFlowService;
|
private readonly authFlows: OAuthLoginFlowService;
|
||||||
|
private readonly logger: AuthServiceLogger;
|
||||||
private readonly listeners = new Set<AuthChangeListener>();
|
private readonly listeners = new Set<AuthChangeListener>();
|
||||||
|
|
||||||
constructor(deps: AuthServiceDependencies = {}) {
|
private constructor(runtime: ModelRuntime, authFlows: OAuthLoginFlowService, logger: AuthServiceLogger) {
|
||||||
this.modelRegistry = deps.modelRegistry ?? (deps.agentDir === undefined ? ModelRegistry.create(AuthStorage.create()) : createModelRegistryForAgentDir(deps.agentDir));
|
this.runtime = runtime;
|
||||||
this.authFlows = deps.authFlows ?? new OAuthLoginFlowService();
|
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 {
|
subscribe(listener: AuthChangeListener): () => void {
|
||||||
@@ -44,34 +70,60 @@ export class AuthService {
|
|||||||
this.listeners.clear();
|
this.listeners.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
authProviders(mode: "login" | "logout", authType?: AuthType): AuthProvidersResponse {
|
async authProviders(mode: "login" | "logout", authType?: AuthType): Promise<AuthProvidersResponse> {
|
||||||
this.modelRegistry.refresh();
|
await this.runtime.reloadConfig();
|
||||||
const providers = mode === "logout" ? getLogoutProviderOptions(this.modelRegistry) : getLoginProviderOptions(this.modelRegistry, authType);
|
const providers = mode === "logout" ? await getLogoutProviderOptions(this.runtime) : getLoginProviderOptions(this.runtime, authType);
|
||||||
return { providers };
|
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");
|
if (key.trim() === "") throw new Error("API key is required");
|
||||||
this.modelRegistry.authStorage.set(providerId, { type: "api_key", key });
|
const provider = await this.requireApiKeyLoginProvider(providerId);
|
||||||
this.refreshAuthState();
|
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 };
|
return { accepted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
logoutProvider(providerId: string): { accepted: true } {
|
async logoutProvider(providerId: string): Promise<{ accepted: true }> {
|
||||||
this.modelRegistry.authStorage.logout(providerId);
|
await this.runtime.logout(providerId);
|
||||||
this.refreshAuthState({ removedProviderId: providerId });
|
await this.emit({ removedProviderId: providerId }, { operation: "logout", providerId });
|
||||||
return { accepted: true };
|
return { accepted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
startOAuthLogin(providerId: string): OAuthFlowState {
|
async startApiKeyLogin(providerId: string): Promise<OAuthFlowState> {
|
||||||
const provider = this.requireOAuthLoginProvider(providerId);
|
const provider = await this.requireApiKeyLoginProvider(providerId);
|
||||||
return this.authFlows.start({
|
return this.authFlows.start({
|
||||||
providerId,
|
providerId,
|
||||||
providerName: provider.name,
|
providerName: provider.name,
|
||||||
authStorage: this.modelRegistry.authStorage,
|
runtime: this.runtime,
|
||||||
onComplete: () => {
|
authType: "api_key",
|
||||||
this.refreshAuthState();
|
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);
|
return this.authFlows.cancel(flowId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private refreshAuthState(change: AuthChange = {}): void {
|
private async emit(change: AuthChange, context: AuthChangeContext): Promise<void> {
|
||||||
this.modelRegistry.authStorage.reload();
|
const results = await Promise.allSettled([...this.listeners].map(async (listener) => listener(change)));
|
||||||
this.modelRegistry.refresh();
|
for (const result of results) {
|
||||||
this.emit(change);
|
if (result.status === "rejected") {
|
||||||
|
this.logErrorNoThrow({ err: result.reason, ...context }, "auth-change listener failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private emit(change: AuthChange): void {
|
private logErrorNoThrow(details: Record<string, unknown>, message: string): void {
|
||||||
for (const listener of this.listeners) listener(change);
|
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) {
|
private async requireApiKeyLoginProvider(providerId: string) {
|
||||||
this.modelRegistry.refresh();
|
await this.runtime.reloadConfig();
|
||||||
const provider = getLoginProviderOptions(this.modelRegistry, "oauth").find((option) => option.id === providerId);
|
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}`);
|
if (provider === undefined) throw new Error(`OAuth provider not found: ${providerId}`);
|
||||||
return provider;
|
return provider;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { OAuthLoginCallbacks } from "@earendil-works/pi-ai";
|
import type { AuthInteraction, AuthType } from "@earendil-works/pi-ai";
|
||||||
import type { AuthStorage } from "@earendil-works/pi-coding-agent";
|
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
|
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
|
||||||
|
|
||||||
type LoginHandler = (providerId: string, callbacks: OAuthLoginCallbacks) => Promise<void>;
|
type LoginHandler = (providerId: string, interaction: AuthInteraction) => Promise<void>;
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
@@ -17,19 +17,19 @@ describe("OAuthLoginFlowService", () => {
|
|||||||
const state = service.start({
|
const state = service.start({
|
||||||
providerId: "test-provider",
|
providerId: "test-provider",
|
||||||
providerName: "Test Provider",
|
providerName: "Test Provider",
|
||||||
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
|
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||||
callbacks.onAuth({ url: "https://example.test/auth", instructions: "Open it" });
|
interaction.notify({ type: "auth_url", url: "https://example.test/auth", instructions: "Open it" });
|
||||||
callbacks.onProgress?.("Waiting for code");
|
interaction.notify({ type: "progress", message: "Waiting for code" });
|
||||||
promptValue = await callbacks.onPrompt({ message: "Paste code", placeholder: "code" });
|
promptValue = await interaction.prompt({ type: "text", message: "Paste code", placeholder: "code" });
|
||||||
callbacks.onProgress?.(`Got ${promptValue}`);
|
interaction.notify({ type: "progress", message: `Got ${promptValue}` });
|
||||||
}),
|
}),
|
||||||
onComplete,
|
onComplete,
|
||||||
});
|
});
|
||||||
|
|
||||||
const prompt = state.prompt;
|
const prompt = state.prompt;
|
||||||
if (prompt === undefined) throw new Error("Expected prompt");
|
if (prompt === undefined) throw new Error("Expected prompt");
|
||||||
expect(state).toMatchObject({ auth: { url: "https://example.test/auth" }, progress: ["Waiting for code"] });
|
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" });
|
expect(prompt).toMatchObject({ message: "Paste code", placeholder: "code", kind: "prompt", promptType: "text", allowEmpty: true });
|
||||||
|
|
||||||
const afterRespond = service.respond(state.flowId, prompt.requestId, "abc123");
|
const afterRespond = service.respond(state.flowId, prompt.requestId, "abc123");
|
||||||
expect(afterRespond.prompt).toBeUndefined();
|
expect(afterRespond.prompt).toBeUndefined();
|
||||||
@@ -41,23 +41,191 @@ describe("OAuthLoginFlowService", () => {
|
|||||||
service.dispose();
|
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 () => {
|
it("round-trips select responses", async () => {
|
||||||
let selectedValue: string | undefined;
|
let selectedValue: string | undefined;
|
||||||
const service = new OAuthLoginFlowService();
|
const service = new OAuthLoginFlowService();
|
||||||
const state = service.start({
|
const state = service.start({
|
||||||
providerId: "test-provider",
|
providerId: "test-provider",
|
||||||
providerName: "Test Provider",
|
providerName: "Test Provider",
|
||||||
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
|
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||||
selectedValue = await callbacks.onSelect({
|
selectedValue = await interaction.prompt({
|
||||||
|
type: "select",
|
||||||
message: "Choose account",
|
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;
|
const select = state.select;
|
||||||
if (select === undefined) throw new Error("Expected select prompt");
|
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");
|
service.respond(state.flowId, select.requestId, "personal");
|
||||||
await flushAsyncLogin();
|
await flushAsyncLogin();
|
||||||
@@ -67,40 +235,97 @@ describe("OAuthLoginFlowService", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses a manual-code prompt for callback-server flows", async () => {
|
it("rejects responses outside the pending select options", () => {
|
||||||
let manualValue: string | undefined;
|
|
||||||
const service = new OAuthLoginFlowService();
|
const service = new OAuthLoginFlowService();
|
||||||
const state = service.start({
|
const state = service.start({
|
||||||
providerId: "test-provider",
|
providerId: "test-provider",
|
||||||
providerName: "Test Provider",
|
providerName: "Test Provider",
|
||||||
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
|
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||||
const manualCodeInput = callbacks.onManualCodeInput;
|
await interaction.prompt({
|
||||||
if (manualCodeInput === undefined) throw new Error("Expected manual-code callback");
|
type: "select",
|
||||||
manualValue = await manualCodeInput();
|
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;
|
const prompt = state.prompt;
|
||||||
if (prompt === undefined) throw new Error("Expected manual 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");
|
service.respond(state.flowId, prompt.requestId, "https://localhost/callback?code=abc");
|
||||||
await flushAsyncLogin();
|
await flushAsyncLogin();
|
||||||
|
|
||||||
expect(manualValue).toBe("https://localhost/callback?code=abc");
|
expect(manualValue).toBe("https://localhost/callback?code=abc");
|
||||||
|
expect(removeAbortListener).toHaveBeenCalledWith("abort", expect.any(Function));
|
||||||
expect(service.get(state.flowId).status).toBe("complete");
|
expect(service.get(state.flowId).status).toBe("complete");
|
||||||
service.dispose();
|
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 () => {
|
it("rejects pending prompts when cancelled", async () => {
|
||||||
const promptRejected = deferred<Error>();
|
const promptRejected = deferred<Error>();
|
||||||
const service = new OAuthLoginFlowService();
|
const service = new OAuthLoginFlowService();
|
||||||
const state = service.start({
|
const state = service.start({
|
||||||
providerId: "test-provider",
|
providerId: "test-provider",
|
||||||
providerName: "Test Provider",
|
providerName: "Test Provider",
|
||||||
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
|
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||||
try {
|
try {
|
||||||
await callbacks.onPrompt({ message: "Paste code" });
|
await interaction.prompt({ type: "text", message: "Paste code" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
promptRejected.resolve(toError(error));
|
promptRejected.resolve(toError(error));
|
||||||
throw error;
|
throw error;
|
||||||
@@ -122,9 +347,9 @@ describe("OAuthLoginFlowService", () => {
|
|||||||
const state = service.start({
|
const state = service.start({
|
||||||
providerId: "test-provider",
|
providerId: "test-provider",
|
||||||
providerName: "Test Provider",
|
providerName: "Test Provider",
|
||||||
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
|
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||||
try {
|
try {
|
||||||
await callbacks.onPrompt({ message: "Paste code" });
|
await interaction.prompt({ type: "text", message: "Paste code" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
promptRejected.resolve(toError(error));
|
promptRejected.resolve(toError(error));
|
||||||
throw error;
|
throw error;
|
||||||
@@ -137,7 +362,7 @@ describe("OAuthLoginFlowService", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
|
|
||||||
await expect(promptRejected.promise).resolves.toMatchObject({ message: "Login cancelled" });
|
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", () => {
|
it("rejects stale or duplicate responses", () => {
|
||||||
@@ -145,8 +370,8 @@ describe("OAuthLoginFlowService", () => {
|
|||||||
const state = service.start({
|
const state = service.start({
|
||||||
providerId: "test-provider",
|
providerId: "test-provider",
|
||||||
providerName: "Test Provider",
|
providerName: "Test Provider",
|
||||||
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
|
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||||
await callbacks.onPrompt({ message: "Paste code" });
|
await interaction.prompt({ type: "text", message: "Paste code" });
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,7 +379,7 @@ describe("OAuthLoginFlowService", () => {
|
|||||||
if (prompt === undefined) throw new Error("Expected prompt");
|
if (prompt === undefined) throw new Error("Expected prompt");
|
||||||
|
|
||||||
service.respond(state.flowId, prompt.requestId, "abc123");
|
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();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -165,9 +390,9 @@ describe("OAuthLoginFlowService", () => {
|
|||||||
const state = service.start({
|
const state = service.start({
|
||||||
providerId: "test-provider",
|
providerId: "test-provider",
|
||||||
providerName: "Test Provider",
|
providerName: "Test Provider",
|
||||||
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
|
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||||
try {
|
try {
|
||||||
await callbacks.onPrompt({ message: "Paste code" });
|
await interaction.prompt({ type: "text", message: "Paste code" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
promptRejected.resolve(toError(error));
|
promptRejected.resolve(toError(error));
|
||||||
throw error;
|
throw error;
|
||||||
@@ -177,18 +402,25 @@ describe("OAuthLoginFlowService", () => {
|
|||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(1000);
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
|
||||||
expect(service.get(state.flowId)).toMatchObject({ status: "error", error: "OAuth login flow expired" });
|
expect(service.get(state.flowId)).toMatchObject({ status: "error", error: "Login flow expired" });
|
||||||
await expect(promptRejected.promise).resolves.toMatchObject({ message: "OAuth login flow expired" });
|
await expect(promptRejected.promise).resolves.toMatchObject({ message: "Login flow expired" });
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(1000);
|
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();
|
service.dispose();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function fakeAuthStorage(login: LoginHandler): Pick<AuthStorage, "login"> {
|
function fakeRuntime(login: LoginHandler, authTypes?: AuthType[]): Pick<ModelRuntime, "login"> {
|
||||||
return { 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> {
|
async function flushAsyncLogin(): Promise<void> {
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import type { OAuthLoginCallbacks, OAuthSelectPrompt, OAuthPrompt } from "@earendil-works/pi-ai";
|
import type { AuthEvent, AuthInteraction, AuthPrompt, AuthType } from "@earendil-works/pi-ai";
|
||||||
import type { AuthStorage } from "@earendil-works/pi-coding-agent";
|
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||||
import type { CommandOption, OAuthFlowState } from "../../shared/apiTypes.js";
|
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 TimerHandle = ReturnType<typeof setTimeout>;
|
||||||
|
type SelectPrompt = Extract<AuthPrompt, { type: "select" }>;
|
||||||
|
type ValuePrompt = Exclude<AuthPrompt, { type: "select" }>;
|
||||||
|
|
||||||
interface PendingOAuthRequest {
|
interface PendingOAuthRequest {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
allowEmpty: boolean;
|
allowEmpty: boolean;
|
||||||
resolve: (value: string | undefined) => void;
|
resolve: (value: string) => void;
|
||||||
reject: (error: Error) => void;
|
reject: (error: Error) => void;
|
||||||
|
allowedValues?: ReadonlySet<string>;
|
||||||
|
cleanup?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OAuthFlowRecord {
|
interface OAuthFlowRecord {
|
||||||
@@ -22,32 +27,46 @@ interface OAuthFlowRecord {
|
|||||||
cleanupTimer?: TimerHandle;
|
cleanupTimer?: TimerHandle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OAuthLoginFlowLogger {
|
||||||
|
error(details: Record<string, unknown>, message: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OAuthLoginFlowServiceOptions {
|
export interface OAuthLoginFlowServiceOptions {
|
||||||
terminalTtlMs?: number;
|
terminalTtlMs?: number;
|
||||||
runningTtlMs?: number;
|
runningTtlMs?: number;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
|
logger?: OAuthLoginFlowLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_TERMINAL_TTL_MS = 5 * 60 * 1000;
|
const DEFAULT_TERMINAL_TTL_MS = 5 * 60 * 1000;
|
||||||
const DEFAULT_RUNNING_TTL_MS = 30 * 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 {
|
export class OAuthLoginFlowService {
|
||||||
private readonly flows = new Map<string, OAuthFlowRecord>();
|
private readonly flows = new Map<string, OAuthFlowRecord>();
|
||||||
private readonly terminalTtlMs: number;
|
private readonly terminalTtlMs: number;
|
||||||
private readonly runningTtlMs: number;
|
private readonly runningTtlMs: number;
|
||||||
private readonly now: () => number;
|
private readonly now: () => number;
|
||||||
|
private readonly logger: OAuthLoginFlowLogger;
|
||||||
|
|
||||||
constructor(options: OAuthLoginFlowServiceOptions = {}) {
|
constructor(options: OAuthLoginFlowServiceOptions = {}) {
|
||||||
this.terminalTtlMs = options.terminalTtlMs ?? DEFAULT_TERMINAL_TTL_MS;
|
this.terminalTtlMs = options.terminalTtlMs ?? DEFAULT_TERMINAL_TTL_MS;
|
||||||
this.runningTtlMs = options.runningTtlMs ?? DEFAULT_RUNNING_TTL_MS;
|
this.runningTtlMs = options.runningTtlMs ?? DEFAULT_RUNNING_TTL_MS;
|
||||||
this.now = options.now ?? (() => Date.now());
|
this.now = options.now ?? (() => Date.now());
|
||||||
|
this.logger = options.logger ?? noopLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
start(options: {
|
start(options: {
|
||||||
providerId: string;
|
providerId: string;
|
||||||
providerName: string;
|
providerName: string;
|
||||||
authStorage: OAuthLoginStorage;
|
runtime: OAuthLoginRuntime;
|
||||||
onComplete?: () => void;
|
/** Defaults to OAuth so established callers retain their existing behavior. */
|
||||||
|
authType?: AuthType;
|
||||||
|
onComplete?: () => void | Promise<void>;
|
||||||
}): OAuthFlowState {
|
}): OAuthFlowState {
|
||||||
const flowId = crypto.randomUUID();
|
const flowId = crypto.randomUUID();
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
@@ -66,58 +85,43 @@ export class OAuthLoginFlowService {
|
|||||||
this.flows.set(flowId, record);
|
this.flows.set(flowId, record);
|
||||||
this.scheduleRunningExpiry(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,
|
signal: abort.signal,
|
||||||
onAuth: (info) => {
|
prompt: (prompt) => this.handlePrompt(record, prompt),
|
||||||
if (!this.isCurrentRunning(record)) return;
|
notify: (event) => { this.handleEvent(record, event); },
|
||||||
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] });
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
void options.authStorage.login(options.providerId, callbacks)
|
void options.runtime.login(options.providerId, options.authType ?? "oauth", interaction).then(
|
||||||
.then(() => {
|
() => this.reconcileCommittedLogin(record, options.onComplete),
|
||||||
if (!this.isCurrentRunning(record)) return;
|
(error: unknown) => {
|
||||||
record.pending = undefined;
|
if (!this.isCurrent(record)) return;
|
||||||
this.markTerminal(record, { ...withoutInteraction(record.state), status: "complete", progress: [...record.state.progress, "Login complete"] });
|
this.clearPending(record);
|
||||||
options.onComplete?.();
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
if (this.flows.get(record.flowId) !== record) return;
|
|
||||||
record.pending = undefined;
|
|
||||||
if (record.state.status !== "running") return;
|
if (record.state.status !== "running") return;
|
||||||
this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: error instanceof Error ? error.message : String(error) });
|
this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: error instanceof Error ? error.message : String(error) });
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return this.get(flowId);
|
return this.get(flowId);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(flowId: string): OAuthFlowState {
|
get(flowId: string): OAuthFlowState {
|
||||||
const record = this.flows.get(flowId);
|
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);
|
return cloneState(record.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
respond(flowId: string, requestId: string, value: string): OAuthFlowState {
|
respond(flowId: string, requestId: string, value: string): OAuthFlowState {
|
||||||
const record = this.flows.get(flowId);
|
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);
|
if (record.state.status !== "running") return cloneState(record.state);
|
||||||
const pending = record.pending;
|
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");
|
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));
|
this.updateState(record, withoutInteraction(record.state));
|
||||||
pending.resolve(value);
|
pending.resolve(value);
|
||||||
return cloneState(record.state);
|
return cloneState(record.state);
|
||||||
@@ -125,11 +129,10 @@ export class OAuthLoginFlowService {
|
|||||||
|
|
||||||
cancel(flowId: string): OAuthFlowState {
|
cancel(flowId: string): OAuthFlowState {
|
||||||
const record = this.flows.get(flowId);
|
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") {
|
if (record.state.status === "running") {
|
||||||
record.abort.abort();
|
record.abort.abort();
|
||||||
const pending = record.pending;
|
const pending = this.clearPending(record);
|
||||||
record.pending = undefined;
|
|
||||||
this.markTerminal(record, { ...withoutInteraction(record.state), status: "cancelled", error: "Login cancelled" });
|
this.markTerminal(record, { ...withoutInteraction(record.state), status: "cancelled", error: "Login cancelled" });
|
||||||
pending?.reject(new Error("Login cancelled"));
|
pending?.reject(new Error("Login cancelled"));
|
||||||
}
|
}
|
||||||
@@ -140,51 +143,179 @@ export class OAuthLoginFlowService {
|
|||||||
for (const record of this.flows.values()) {
|
for (const record of this.flows.values()) {
|
||||||
this.clearTimer(record);
|
this.clearTimer(record);
|
||||||
record.abort.abort();
|
record.abort.abort();
|
||||||
const pending = record.pending;
|
const pending = this.clearPending(record);
|
||||||
record.pending = undefined;
|
|
||||||
pending?.reject(new Error("Login cancelled"));
|
pending?.reject(new Error("Login cancelled"));
|
||||||
}
|
}
|
||||||
this.flows.clear();
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!this.isCurrentRunning(record)) {
|
if (!this.isCurrentRunning(record)) {
|
||||||
reject(new Error("Login cancelled"));
|
reject(new Error("Login cancelled"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const requestId = crypto.randomUUID();
|
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);
|
const base = withoutInteraction(record.state);
|
||||||
this.updateState(record, {
|
this.updateState(record, {
|
||||||
...base,
|
...base,
|
||||||
prompt: {
|
prompt: {
|
||||||
requestId,
|
requestId,
|
||||||
message: prompt.message,
|
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.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) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!this.isCurrentRunning(record)) {
|
if (!this.isCurrentRunning(record)) {
|
||||||
reject(new Error("Login cancelled"));
|
reject(new Error("Login cancelled"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const requestId = crypto.randomUUID();
|
const requestId = crypto.randomUUID();
|
||||||
const options: CommandOption[] = prompt.options.map((option) => ({ value: option.id, label: option.label }));
|
const options: CommandOption[] = prompt.options.map((option) => ({
|
||||||
record.pending = { requestId, allowEmpty: true, resolve, reject };
|
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);
|
const base = withoutInteraction(record.state);
|
||||||
this.updateState(record, { ...base, select: { requestId, message: prompt.message, options } });
|
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 {
|
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 {
|
private updateState(record: OAuthFlowRecord, state: OAuthFlowState): void {
|
||||||
@@ -226,10 +357,9 @@ export class OAuthLoginFlowService {
|
|||||||
private expireRunningFlow(record: OAuthFlowRecord): void {
|
private expireRunningFlow(record: OAuthFlowRecord): void {
|
||||||
if (!this.isCurrentRunning(record)) return;
|
if (!this.isCurrentRunning(record)) return;
|
||||||
record.abort.abort();
|
record.abort.abort();
|
||||||
const pending = record.pending;
|
const pending = this.clearPending(record);
|
||||||
record.pending = undefined;
|
this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: "Login flow expired" });
|
||||||
this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: "OAuth login flow expired" });
|
pending?.reject(new Error("Login flow expired"));
|
||||||
pending?.reject(new Error("OAuth login flow expired"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private setTimer(record: OAuthFlowRecord, delayMs: number, callback: () => void): void {
|
private setTimer(record: OAuthFlowRecord, delayMs: number, callback: () => void): void {
|
||||||
@@ -256,9 +386,20 @@ function cloneState(state: OAuthFlowState): OAuthFlowState {
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
progress: [...state.progress],
|
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.prompt === undefined ? {} : { prompt: { ...state.prompt } }),
|
||||||
...(state.select === undefined ? {} : { select: { ...state.select, options: state.select.options.map((option) => ({ ...option })) } }),
|
...(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 { describe, expect, it, vi } from "vitest";
|
||||||
import { PiSessionService } from "./piSessionService.js";
|
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";
|
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 fake = fakeRuntime("root", { sessionFile: root.path });
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
archiveStore: {
|
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 }]),
|
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 deletedSessionIds: string[] = [];
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
archiveStore: {
|
archiveStore: {
|
||||||
list: () => Promise.resolve([]),
|
list: () => Promise.resolve([]),
|
||||||
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
|
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 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(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
archiveStore: {
|
archiveStore: {
|
||||||
list: () => Promise.resolve([]),
|
list: () => Promise.resolve([]),
|
||||||
get: () => Promise.resolve(undefined),
|
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 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(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: () => {
|
createAgentRuntime: () => {
|
||||||
createCalls += 1;
|
createCalls += 1;
|
||||||
return Promise.resolve(busy.runtime);
|
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 deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(busy.runtime),
|
createAgentRuntime: runtimeCreator(busy.runtime),
|
||||||
archiveStore: {
|
archiveStore: {
|
||||||
list: () => Promise.resolve([busyRecord, idleRecord]),
|
list: () => Promise.resolve([busyRecord, idleRecord]),
|
||||||
@@ -196,6 +201,7 @@ describe("PiSessionService archive and cleanup", () => {
|
|||||||
const listCalls: string[] = [];
|
const listCalls: string[] = [];
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
archiveStore: {
|
archiveStore: {
|
||||||
list: () => Promise.resolve([
|
list: () => Promise.resolve([
|
||||||
{ sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" },
|
{ 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 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(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
||||||
archiveStore: {
|
archiveStore: {
|
||||||
list: () => Promise.resolve([archived, otherArchived]),
|
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 deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
||||||
archiveStore: {
|
archiveStore: {
|
||||||
list: () => Promise.resolve([
|
list: () => Promise.resolve([
|
||||||
@@ -334,6 +342,7 @@ describe("PiSessionService archive and cleanup", () => {
|
|||||||
const archivedInputs: string[] = [];
|
const archivedInputs: string[] = [];
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
archiveStore: {
|
archiveStore: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { PiSessionService, type PiAgentSession, type PiSessionRuntime } from "./piSessionService.js";
|
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";
|
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
};
|
};
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime,
|
createAgentRuntime,
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -60,6 +61,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
try {
|
try {
|
||||||
service = new PiSessionService(hub, {
|
service = new PiSessionService(hub, {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -87,6 +89,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
const open = vi.fn(() => fakeSessionManager());
|
const open = vi.fn(() => fakeSessionManager());
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: {
|
sessionManager: {
|
||||||
create: () => fakeSessionManager(),
|
create: () => fakeSessionManager(),
|
||||||
@@ -136,6 +139,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
const open = vi.spyOn(gateway, "open");
|
const open = vi.spyOn(gateway, "open");
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
archiveStore: emptyArchiveStore(),
|
archiveStore: emptyArchiveStore(),
|
||||||
createAgentRuntime,
|
createAgentRuntime,
|
||||||
sessionManager: gateway,
|
sessionManager: gateway,
|
||||||
@@ -190,6 +194,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
};
|
};
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
archiveStore: emptyArchiveStore(),
|
archiveStore: emptyArchiveStore(),
|
||||||
createAgentRuntime,
|
createAgentRuntime,
|
||||||
sessionManager: sessionGateway([sessionRecord(sessionId)]),
|
sessionManager: sessionGateway([sessionRecord(sessionId)]),
|
||||||
@@ -230,6 +235,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
const fake = fakeRuntime(sessionId);
|
const fake = fakeRuntime(sessionId);
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
archiveStore: emptyArchiveStore(),
|
archiveStore: emptyArchiveStore(),
|
||||||
createAgentRuntime: () => {
|
createAgentRuntime: () => {
|
||||||
createStarted.resolve();
|
createStarted.resolve();
|
||||||
@@ -264,6 +270,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
fake.runtime.setRebindSession = (callback) => { rebindSession = callback; };
|
fake.runtime.setRebindSession = (callback) => { rebindSession = callback; };
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -291,6 +298,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
});
|
});
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -326,6 +334,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
});
|
});
|
||||||
service = new PiSessionService(hub, {
|
service = new PiSessionService(hub, {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("idle-session")]),
|
sessionManager: sessionGateway([sessionRecord("idle-session")]),
|
||||||
heartbeatIntervalMs: 1_000,
|
heartbeatIntervalMs: 1_000,
|
||||||
@@ -362,6 +371,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
});
|
});
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("completion-session")]),
|
sessionManager: sessionGateway([sessionRecord("completion-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -381,6 +391,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
it("uses injected archive and session-manager gateways for listing", async () => {
|
it("uses injected archive and session-manager gateways for listing", async () => {
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
archiveStore: {
|
archiveStore: {
|
||||||
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]),
|
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]),
|
||||||
get: () => Promise.resolve(undefined),
|
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 () => {
|
it("lists archived records that have been moved out of the active session directory", async () => {
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
archiveStore: {
|
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" }]),
|
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),
|
get: () => Promise.resolve(undefined),
|
||||||
@@ -442,6 +454,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
const fake = fakeRuntime("runtime-reload-session");
|
const fake = fakeRuntime("runtime-reload-session");
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]),
|
sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -475,6 +488,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
};
|
};
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime,
|
createAgentRuntime,
|
||||||
sessionManager: sessionGateway([sessionRecord("reload-session")]),
|
sessionManager: sessionGateway([sessionRecord("reload-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -499,6 +513,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
const fake = fakeRuntime("busy-session", { isStreaming: true });
|
const fake = fakeRuntime("busy-session", { isStreaming: true });
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("busy-session")]),
|
sessionManager: sessionGateway([sessionRecord("busy-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -514,6 +529,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
it("refuses to reload an archived session", async () => {
|
it("refuses to reload an archived session", async () => {
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
archiveStore: {
|
archiveStore: {
|
||||||
list: () => Promise.resolve([]),
|
list: () => Promise.resolve([]),
|
||||||
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
|
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 reconciliations: { cwd: string; sessionIds: string[] }[] = [];
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
archiveStore: {
|
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" }]),
|
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),
|
get: () => Promise.resolve(undefined),
|
||||||
@@ -573,6 +590,7 @@ describe("PiSessionService.streamSnapshot", () => {
|
|||||||
const fake = fakeRuntime("snap-idle");
|
const fake = fakeRuntime("snap-idle");
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -601,6 +619,7 @@ describe("PiSessionService.streamSnapshot", () => {
|
|||||||
const fake = fakeRuntime("snap-live", { state: { streamingMessage } });
|
const fake = fakeRuntime("snap-live", { state: { streamingMessage } });
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
heartbeatIntervalMs: 60_000,
|
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 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 { describe, expect, it, vi } from "vitest";
|
||||||
import { PiSessionService } from "./piSessionService.js";
|
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";
|
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 fake = fakeRuntime("prompt-session");
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
|
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -30,6 +34,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
const hub = new CapturingSessionEventHub();
|
const hub = new CapturingSessionEventHub();
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("echo-session")]),
|
sessionManager: sessionGateway([sessionRecord("echo-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -60,6 +65,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
};
|
};
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime,
|
createAgentRuntime,
|
||||||
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
|
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -96,6 +102,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
const fake = fakeRuntime("name-session", { model, agent: { streamFn } });
|
const fake = fakeRuntime("name-session", { model, agent: { streamFn } });
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("name-session")]),
|
sessionManager: sessionGateway([sessionRecord("name-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -118,6 +125,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
});
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("status-session")]),
|
sessionManager: sessionGateway([sessionRecord("status-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -139,6 +147,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
});
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("dedupe-session")]),
|
sessionManager: sessionGateway([sessionRecord("dedupe-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -155,6 +164,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
const fake = fakeRuntime("queued-session", { isStreaming: true });
|
const fake = fakeRuntime("queued-session", { isStreaming: true });
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("queued-session")]),
|
sessionManager: sessionGateway([sessionRecord("queued-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -181,6 +191,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
};
|
};
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("compacting-session")]),
|
sessionManager: sessionGateway([sessionRecord("compacting-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -250,6 +261,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
fake.session.clearQueue = clearRuntimeQueue;
|
fake.session.clearQueue = clearRuntimeQueue;
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("clear-queue-session")]),
|
sessionManager: sessionGateway([sessionRecord("clear-queue-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -290,6 +302,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
const fake = fakeRuntime("clear-empty-queue-session");
|
const fake = fakeRuntime("clear-empty-queue-session");
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("clear-empty-queue-session")]),
|
sessionManager: sessionGateway([sessionRecord("clear-empty-queue-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -309,6 +322,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
const fake = fakeRuntime("abort-session");
|
const fake = fakeRuntime("abort-session");
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("abort-session")]),
|
sessionManager: sessionGateway([sessionRecord("abort-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -326,6 +340,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
const fake = fakeRuntime("abort-compaction-session", { isCompacting: true });
|
const fake = fakeRuntime("abort-compaction-session", { isCompacting: true });
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]),
|
sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -341,17 +356,66 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
await service.dispose();
|
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 () => {
|
it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => {
|
||||||
const hub = new CapturingSessionEventHub();
|
const hub = new CapturingSessionEventHub();
|
||||||
const authStorage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "sk-test" } });
|
// The shared model runtime reads a live credential store. Mutating the store
|
||||||
const modelRegistry = ModelRegistry.inMemory(authStorage);
|
// and refreshing here simulates the committed snapshot that
|
||||||
const model = modelRegistry.find(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
|
// 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");
|
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, {
|
const service = new PiSessionService(hub, {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
modelRegistry,
|
modelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("auth-session")]),
|
sessionManager: sessionGateway([sessionRecord("auth-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -361,7 +425,8 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
hub.sessionEvents.length = 0;
|
hub.sessionEvents.length = 0;
|
||||||
hub.globalEvents.length = 0;
|
hub.globalEvents.length = 0;
|
||||||
|
|
||||||
authStorage.logout("anthropic");
|
await credentials.delete("anthropic");
|
||||||
|
await modelRuntime.refresh();
|
||||||
service.applyAuthChange({ removedProviderId: "anthropic" });
|
service.applyAuthChange({ removedProviderId: "anthropic" });
|
||||||
service.applyAuthChange({ removedProviderId: "anthropic" });
|
service.applyAuthChange({ removedProviderId: "anthropic" });
|
||||||
|
|
||||||
@@ -369,9 +434,11 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
expect(warningCount()).toBe(1);
|
expect(warningCount()).toBe(1);
|
||||||
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "auth-session")).toBe(true);
|
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();
|
service.applyAuthChange();
|
||||||
authStorage.logout("anthropic");
|
await credentials.delete("anthropic");
|
||||||
|
await modelRuntime.refresh();
|
||||||
service.applyAuthChange({ removedProviderId: "anthropic" });
|
service.applyAuthChange({ removedProviderId: "anthropic" });
|
||||||
expect(warningCount()).toBe(2);
|
expect(warningCount()).toBe(2);
|
||||||
|
|
||||||
@@ -382,6 +449,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
const fake = fakeRuntime("stop-session");
|
const fake = fakeRuntime("stop-session");
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("stop-session")]),
|
sessionManager: sessionGateway([sessionRecord("stop-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -394,3 +462,25 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
await service.dispose();
|
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 { describe, expect, it } from "vitest";
|
||||||
import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
|
import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
|
||||||
import type { SpawnTargetDecision } from "./spawnTargetResolver.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";
|
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ describe("PiSessionService", () => {
|
|||||||
const log: { details: Record<string, unknown>; message: string }[] = [];
|
const log: { details: Record<string, unknown>; message: string }[] = [];
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
|
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
|
||||||
@@ -45,6 +46,7 @@ describe("PiSessionService", () => {
|
|||||||
};
|
};
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime,
|
createAgentRuntime,
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
|
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
|
||||||
@@ -80,6 +82,7 @@ describe("PiSessionService", () => {
|
|||||||
const fake = fakeRuntime("spawned-x");
|
const fake = fakeRuntime("spawned-x");
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
|
import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
|
||||||
import type { SpawnTargetDecision } from "./spawnTargetResolver.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";
|
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ describe("PiSessionService", () => {
|
|||||||
};
|
};
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime,
|
createAgentRuntime,
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
archiveStore,
|
archiveStore,
|
||||||
@@ -81,6 +82,7 @@ describe("PiSessionService", () => {
|
|||||||
};
|
};
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime,
|
createAgentRuntime,
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
archiveStore: emptyArchiveStore(),
|
archiveStore: emptyArchiveStore(),
|
||||||
@@ -121,6 +123,7 @@ describe("PiSessionService", () => {
|
|||||||
let index = 0;
|
let index = 0;
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: () => {
|
createAgentRuntime: () => {
|
||||||
const runtime = runtimes[index] ?? child.runtime;
|
const runtime = runtimes[index] ?? child.runtime;
|
||||||
index += 1;
|
index += 1;
|
||||||
@@ -173,6 +176,7 @@ describe("PiSessionService", () => {
|
|||||||
const open = vi.fn(() => childManager);
|
const open = vi.fn(() => childManager);
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: () => {
|
createAgentRuntime: () => {
|
||||||
const runtime = runtimes[index] ?? child.runtime;
|
const runtime = runtimes[index] ?? child.runtime;
|
||||||
index += 1;
|
index += 1;
|
||||||
@@ -215,6 +219,7 @@ describe("PiSessionService", () => {
|
|||||||
});
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(parent.runtime),
|
createAgentRuntime: runtimeCreator(parent.runtime),
|
||||||
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
||||||
archiveStore: emptyArchiveStore(),
|
archiveStore: emptyArchiveStore(),
|
||||||
@@ -240,6 +245,7 @@ describe("PiSessionService", () => {
|
|||||||
});
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(parent.runtime),
|
createAgentRuntime: runtimeCreator(parent.runtime),
|
||||||
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
||||||
archiveStore: emptyArchiveStore(),
|
archiveStore: emptyArchiveStore(),
|
||||||
@@ -262,6 +268,7 @@ describe("PiSessionService", () => {
|
|||||||
});
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(parent.runtime),
|
createAgentRuntime: runtimeCreator(parent.runtime),
|
||||||
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
||||||
archiveStore: emptyArchiveStore(),
|
archiveStore: emptyArchiveStore(),
|
||||||
@@ -283,6 +290,7 @@ describe("PiSessionService", () => {
|
|||||||
});
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(parent.runtime),
|
createAgentRuntime: runtimeCreator(parent.runtime),
|
||||||
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() },
|
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() },
|
||||||
archiveStore: emptyArchiveStore(),
|
archiveStore: emptyArchiveStore(),
|
||||||
@@ -304,6 +312,7 @@ describe("PiSessionService", () => {
|
|||||||
});
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(forkedParent.runtime),
|
createAgentRuntime: runtimeCreator(forkedParent.runtime),
|
||||||
sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
||||||
archiveStore: emptyArchiveStore(),
|
archiveStore: emptyArchiveStore(),
|
||||||
@@ -343,6 +352,7 @@ describe("PiSessionService", () => {
|
|||||||
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
|
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: (_createRuntime, options) => {
|
createAgentRuntime: (_createRuntime, options) => {
|
||||||
delegationCapabilities.push(options.delegationToolsEnabled);
|
delegationCapabilities.push(options.delegationToolsEnabled);
|
||||||
const runtime = runtimes[index] ?? parent.runtime;
|
const runtime = runtimes[index] ?? parent.runtime;
|
||||||
@@ -409,6 +419,7 @@ describe("PiSessionService", () => {
|
|||||||
});
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: () => {
|
createAgentRuntime: () => {
|
||||||
const runtime = runtimes[index] ?? parent.runtime;
|
const runtime = runtimes[index] ?? parent.runtime;
|
||||||
index += 1;
|
index += 1;
|
||||||
@@ -469,6 +480,7 @@ describe("PiSessionService", () => {
|
|||||||
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
|
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: () => {
|
createAgentRuntime: () => {
|
||||||
const runtime = runtimes[index] ?? parent.runtime;
|
const runtime = runtimes[index] ?? parent.runtime;
|
||||||
index += 1;
|
index += 1;
|
||||||
@@ -536,6 +548,7 @@ describe("PiSessionService", () => {
|
|||||||
});
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime,
|
createAgentRuntime,
|
||||||
sessionManager: {
|
sessionManager: {
|
||||||
create: () => parentManager,
|
create: () => parentManager,
|
||||||
@@ -614,6 +627,7 @@ describe("PiSessionService", () => {
|
|||||||
});
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime,
|
createAgentRuntime,
|
||||||
sessionManager: {
|
sessionManager: {
|
||||||
create: () => copiedParentManager,
|
create: () => copiedParentManager,
|
||||||
@@ -674,6 +688,7 @@ describe("PiSessionService", () => {
|
|||||||
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
|
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: () => {
|
createAgentRuntime: () => {
|
||||||
const runtime = runtimes[index] ?? parent.runtime;
|
const runtime = runtimes[index] ?? parent.runtime;
|
||||||
index += 1;
|
index += 1;
|
||||||
@@ -728,6 +743,7 @@ describe("PiSessionService", () => {
|
|||||||
const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager);
|
const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager);
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: () => {
|
createAgentRuntime: () => {
|
||||||
const runtime = runtimes[index] ?? parent.runtime;
|
const runtime = runtimes[index] ?? parent.runtime;
|
||||||
index += 1;
|
index += 1;
|
||||||
@@ -770,6 +786,7 @@ describe("PiSessionService", () => {
|
|||||||
const open = vi.fn(() => childManager);
|
const open = vi.fn(() => childManager);
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(child.runtime),
|
createAgentRuntime: runtimeCreator(child.runtime),
|
||||||
sessionManager: {
|
sessionManager: {
|
||||||
create: () => childManager,
|
create: () => childManager,
|
||||||
@@ -959,6 +976,7 @@ describe("PiSessionService", () => {
|
|||||||
const fake = fakeRuntime("nope");
|
const fake = fakeRuntime("nope");
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
heartbeatIntervalMs: 60_000,
|
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 type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
|
||||||
import { SessionEventHub } from "../realtime/sessionEventHub.js";
|
import { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||||
import type { PiAgentSession, PiSessionManager, PiSessionRuntime, PiSessionServiceDependencies } from "./piSessionService.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_PROVIDER = "anthropic";
|
||||||
export const TEST_MODEL_ID = "claude-sonnet-4-5-20250929";
|
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"]> {
|
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");
|
if (model === undefined) throw new Error("test model not found");
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
@@ -88,7 +115,7 @@ export function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession>
|
|||||||
pendingMessageCount: 0,
|
pendingMessageCount: 0,
|
||||||
sessionManager: fakeSessionManager(),
|
sessionManager: fakeSessionManager(),
|
||||||
settingsManager: { getWarnings: () => ({}), setWarnings: () => undefined },
|
settingsManager: { getWarnings: () => ({}), setWarnings: () => undefined },
|
||||||
modelRegistry: ModelRegistry.create(AuthStorage.inMemory()),
|
modelRuntime: testModelRuntime,
|
||||||
scopedModels: [],
|
scopedModels: [],
|
||||||
extensionRunner: { getRegisteredCommands: () => [] },
|
extensionRunner: { getRegisteredCommands: () => [] },
|
||||||
promptTemplates: [],
|
promptTemplates: [],
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import { statSync } from "node:fs";
|
import { statSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
import { open, readFile, writeFile } from "node:fs/promises";
|
import { open, readFile, writeFile } from "node:fs/promises";
|
||||||
import type { ImageContent } from "@earendil-works/pi-ai";
|
import type { ImageContent } from "@earendil-works/pi-ai";
|
||||||
import type { StreamFn } from "@earendil-works/pi-agent-core";
|
import type { StreamFn } from "@earendil-works/pi-agent-core";
|
||||||
import {
|
import {
|
||||||
AuthStorage,
|
|
||||||
createAgentSessionFromServices,
|
createAgentSessionFromServices,
|
||||||
createAgentSessionRuntime,
|
createAgentSessionRuntime,
|
||||||
createAgentSessionServices,
|
createAgentSessionServices,
|
||||||
createEditToolDefinition,
|
createEditToolDefinition,
|
||||||
defineTool,
|
defineTool,
|
||||||
ModelRegistry,
|
readStoredCredential,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
type AgentSessionRuntimeDiagnostic,
|
type AgentSessionRuntimeDiagnostic,
|
||||||
type AgentSessionServices,
|
type AgentSessionServices,
|
||||||
type CreateAgentSessionRuntimeFactory,
|
type CreateAgentSessionRuntimeFactory,
|
||||||
type EditToolDetails,
|
type EditToolDetails,
|
||||||
|
type ModelRuntime,
|
||||||
type ResourceDiagnostic,
|
type ResourceDiagnostic,
|
||||||
} from "@earendil-works/pi-coding-agent";
|
} from "@earendil-works/pi-coding-agent";
|
||||||
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionStreamSnapshot, SessionUiEvent } from "../types.js";
|
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 { SessionArchiveStore, type ArchivedSessionRecord, type ArchiveSessionInput } from "./sessionArchiveStore.js";
|
||||||
import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree, type SessionArchiveTreeCandidate } from "./sessionArchiveTree.js";
|
import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree, type SessionArchiveTreeCandidate } from "./sessionArchiveTree.js";
|
||||||
import type { ActiveSession } from "./sessionRuntimeStore.js";
|
import type { ActiveSession } from "./sessionRuntimeStore.js";
|
||||||
import { createModelRegistryForAgentDir, type AuthChange } from "./authService.js";
|
|
||||||
import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
|
import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
|
||||||
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
|
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
|
||||||
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.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 { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionWarning } from "../../shared/apiTypes.js";
|
||||||
import type { SessionRouteLookup, SessionRouteRef, SessionRouteService } from "./sessionService.js";
|
import type { SessionRouteLookup, SessionRouteRef, SessionRouteService } from "./sessionService.js";
|
||||||
|
|
||||||
|
import { type AuthChange } from "./authService.js";
|
||||||
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
||||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||||
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
|
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
|
||||||
@@ -171,7 +172,6 @@ interface BulkDeletePlanItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AgentModel = NonNullable<SpawnSessionInvocation["model"]>;
|
type AgentModel = NonNullable<SpawnSessionInvocation["model"]>;
|
||||||
type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
|
|
||||||
|
|
||||||
export interface PiSessionManager {
|
export interface PiSessionManager {
|
||||||
getCwd(): string;
|
getCwd(): string;
|
||||||
@@ -208,7 +208,7 @@ interface PiExtensionBindings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PiAgentSession {
|
export interface PiAgentSession {
|
||||||
modelRegistry: ModelRegistryInstance;
|
modelRuntime: ModelRuntime;
|
||||||
/**
|
/**
|
||||||
* Narrow read/write of the SDK `SettingsManager`, exposing only the warning
|
* Narrow read/write of the SDK `SettingsManager`, exposing only the warning
|
||||||
* suppression flags consumed here (e.g. `anthropicExtraUsage`). Used to gate
|
* 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.
|
* synchronous live status computation.
|
||||||
*/
|
*/
|
||||||
export function anthropicSubscriptionWarning(
|
export function anthropicSubscriptionWarning(
|
||||||
session: Pick<PiAgentSession, "model" | "modelRegistry" | "settingsManager">,
|
session: Pick<PiAgentSession, "model" | "settingsManager">,
|
||||||
|
authPath?: string,
|
||||||
): SessionWarning | undefined {
|
): SessionWarning | undefined {
|
||||||
if (session.settingsManager.getWarnings().anthropicExtraUsage === false) return undefined;
|
if (session.settingsManager.getWarnings().anthropicExtraUsage === false) return undefined;
|
||||||
if (session.model?.provider !== "anthropic") 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;
|
if (credential === undefined) return undefined;
|
||||||
const isSubscriptionAuth = credential.type === "oauth"
|
const isSubscriptionAuth = credential.type === "oauth"
|
||||||
? true
|
? true
|
||||||
@@ -487,14 +488,13 @@ export function createPiWebCustomToolDefinitions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultRuntimeFactory(
|
function createDefaultRuntimeFactory(
|
||||||
authStorage: AuthStorage,
|
modelRuntime: ModelRuntime,
|
||||||
modelRegistry: ModelRegistryInstance,
|
|
||||||
sessionManagers: Pick<PiSessionManagerGateway, "open">,
|
sessionManagers: Pick<PiSessionManagerGateway, "open">,
|
||||||
spawn?: SpawnSessionFn,
|
spawn?: SpawnSessionFn,
|
||||||
subsessions?: SubsessionToolDeps,
|
subsessions?: SubsessionToolDeps,
|
||||||
): PiWebCreateAgentSessionRuntimeFactory {
|
): PiWebCreateAgentSessionRuntimeFactory {
|
||||||
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => {
|
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
|
const resolvedDelegationToolsEnabled = delegationToolsEnabled
|
||||||
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
|
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
|
||||||
const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions);
|
const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions);
|
||||||
@@ -539,7 +539,7 @@ export interface PiSessionServiceDependencies {
|
|||||||
archiveStore?: SessionArchiveRepository;
|
archiveStore?: SessionArchiveRepository;
|
||||||
createRuntime?: PiWebCreateAgentSessionRuntimeFactory;
|
createRuntime?: PiWebCreateAgentSessionRuntimeFactory;
|
||||||
createAgentRuntime?: CreateAgentRuntime;
|
createAgentRuntime?: CreateAgentRuntime;
|
||||||
modelRegistry?: ModelRegistryInstance;
|
modelRuntime: ModelRuntime;
|
||||||
heartbeatIntervalMs?: number;
|
heartbeatIntervalMs?: number;
|
||||||
workspaceActivity?: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity">;
|
workspaceActivity?: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity">;
|
||||||
/**
|
/**
|
||||||
@@ -589,7 +589,7 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
private readonly sessionManager: PiSessionManagerGateway;
|
private readonly sessionManager: PiSessionManagerGateway;
|
||||||
private readonly createRuntime: PiWebCreateAgentSessionRuntimeFactory;
|
private readonly createRuntime: PiWebCreateAgentSessionRuntimeFactory;
|
||||||
private readonly createAgentRuntime: CreateAgentRuntime;
|
private readonly createAgentRuntime: CreateAgentRuntime;
|
||||||
private readonly modelRegistry: ModelRegistryInstance;
|
private readonly modelRuntime: ModelRuntime;
|
||||||
private readonly workspaceActivity: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity"> | undefined;
|
private readonly workspaceActivity: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity"> | undefined;
|
||||||
private readonly spawnTargets: SpawnTargetResolver | undefined;
|
private readonly spawnTargets: SpawnTargetResolver | undefined;
|
||||||
private readonly logger: PiSessionLogger;
|
private readonly logger: PiSessionLogger;
|
||||||
@@ -599,7 +599,7 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
|
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
|
||||||
this.agentDir = deps.agentDir;
|
this.agentDir = deps.agentDir;
|
||||||
this.sessionManager = deps.sessionManager;
|
this.sessionManager = deps.sessionManager;
|
||||||
this.modelRegistry = deps.modelRegistry ?? createModelRegistryForAgentDir(this.agentDir);
|
this.modelRuntime = deps.modelRuntime;
|
||||||
this.spawnTargets = deps.spawnTargets;
|
this.spawnTargets = deps.spawnTargets;
|
||||||
this.logger = deps.logger ?? noopLogger;
|
this.logger = deps.logger ?? noopLogger;
|
||||||
this.now = deps.now ?? (() => new Date());
|
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).
|
// also require the spawn capability (they share its project-scope resolver).
|
||||||
const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
|
const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
|
||||||
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(
|
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(
|
||||||
this.modelRegistry.authStorage,
|
this.modelRuntime,
|
||||||
this.modelRegistry,
|
|
||||||
this.sessionManager,
|
this.sessionManager,
|
||||||
this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input),
|
this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input),
|
||||||
!subsessionsActive ? undefined : {
|
!subsessionsActive ? undefined : {
|
||||||
@@ -1159,22 +1158,22 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
|
|
||||||
async availableModels(ref: PiSessionLookup): Promise<ClientSessionModel[]> {
|
async availableModels(ref: PiSessionLookup): Promise<ClientSessionModel[]> {
|
||||||
const session = await this.getOrOpen(ref);
|
const session = await this.getOrOpen(ref);
|
||||||
session.modelRegistry.refresh();
|
await session.modelRuntime.reloadConfig();
|
||||||
const models = session.scopedModels.length > 0
|
const models = session.scopedModels.length > 0
|
||||||
? session.scopedModels.map((scoped) => scoped.model)
|
? session.scopedModels.map((scoped) => scoped.model)
|
||||||
: session.modelRegistry.getAvailable();
|
: session.modelRuntime.getAvailableSnapshot();
|
||||||
return models.map(modelToClientModel);
|
return models.map(modelToClientModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setModel(ref: PiSessionLookup, provider: string, modelId: string): Promise<ClientSessionStatus> {
|
async setModel(ref: PiSessionLookup, provider: string, modelId: string): Promise<ClientSessionStatus> {
|
||||||
await this.assertWritable(ref);
|
await this.assertWritable(ref);
|
||||||
const session = await this.getOrOpen(ref);
|
const session = await this.getOrOpen(ref);
|
||||||
session.modelRegistry.refresh();
|
await session.modelRuntime.reloadConfig();
|
||||||
const candidates = session.scopedModels.length > 0
|
const candidates = session.scopedModels.length > 0
|
||||||
? session.scopedModels.map((scoped) => scoped.model)
|
? session.scopedModels.map((scoped) => scoped.model)
|
||||||
: session.modelRegistry.getAvailable();
|
: session.modelRuntime.getAvailableSnapshot();
|
||||||
const model = candidates.find((candidate) => candidate.provider === provider && candidate.id === modelId)
|
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}`);
|
if (model === undefined) throw new Error(`Model not found: ${provider}/${modelId}`);
|
||||||
await session.setModel(model);
|
await session.setModel(model);
|
||||||
this.publishActivity(session, `model: ${model.id}`, "idle", model.provider);
|
this.publishActivity(session, `model: ${model.id}`, "idle", model.provider);
|
||||||
@@ -2020,10 +2019,11 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyAuthChange(change: AuthChange = {}): void {
|
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()) {
|
for (const active of this.active.values()) {
|
||||||
const { session } = active.runtime;
|
const { session } = active.runtime;
|
||||||
session.modelRegistry.refresh();
|
|
||||||
this.syncCurrentModelAuthWarning(session, change.removedProviderId);
|
this.syncCurrentModelAuthWarning(session, change.removedProviderId);
|
||||||
this.publishStatus(session);
|
this.publishStatus(session);
|
||||||
}
|
}
|
||||||
@@ -2034,9 +2034,9 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
if (model === undefined) return;
|
if (model === undefined) return;
|
||||||
if (model.provider === "unknown" && model.id === "unknown") return;
|
if (model.provider === "unknown" && model.id === "unknown") return;
|
||||||
const warningKey = authLossWarningKey(session.sessionId, model.provider, model.id);
|
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 (registered === undefined) return;
|
||||||
if (session.modelRegistry.hasConfiguredAuth(registered)) {
|
if (session.modelRuntime.hasConfiguredAuth(model.provider)) {
|
||||||
this.authLossWarnings.delete(warningKey);
|
this.authLossWarnings.delete(warningKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2182,7 +2182,7 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
private warningsForSession(session: PiAgentSession): SessionWarning[] {
|
private warningsForSession(session: PiAgentSession): SessionWarning[] {
|
||||||
const runtime = this.active.get(session.sessionId)?.runtime;
|
const runtime = this.active.get(session.sessionId)?.runtime;
|
||||||
const warnings = runtime === undefined ? [] : collectRuntimeWarnings(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);
|
if (anthropic !== undefined) warnings.push(anthropic);
|
||||||
return warnings;
|
return warnings;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { AuthStorage, ModelRegistry, type AgentSessionRuntimeDiagnostic, type ResourceDiagnostic } from "@earendil-works/pi-coding-agent";
|
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 { anthropicSubscriptionWarning, collectRuntimeWarnings, dismissSessionWarning, type RuntimeWarningSources } from "./piSessionService.js";
|
||||||
|
import { testModel } from "./piSessionService.testSupport.js";
|
||||||
import type { PiAgentSession } from "./piSessionService.js";
|
import type { PiAgentSession } from "./piSessionService.js";
|
||||||
import type { SessionWarning } from "../../shared/apiTypes.js";
|
import type { SessionWarning } from "../../shared/apiTypes.js";
|
||||||
|
|
||||||
@@ -83,47 +87,55 @@ describe("collectRuntimeWarnings", () => {
|
|||||||
const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING =
|
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.";
|
"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"] {
|
function anthropicModel(provider: string): PiAgentSession["model"] {
|
||||||
const registry = ModelRegistry.inMemory(AuthStorage.inMemory());
|
// anthropicSubscriptionWarning only reads `model.provider`, so any built-in
|
||||||
const model = registry.getAll().find((candidate) => candidate.provider === provider) ?? registry.getAll()[0];
|
// model re-tagged with the desired provider is a sufficient fixture.
|
||||||
if (model === undefined) throw new Error("expected at least one built-in model");
|
return { ...testModel(), provider };
|
||||||
return { ...model, provider };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function subscriptionSession(options: {
|
function subscriptionSession(options: {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
anthropicExtraUsage?: boolean;
|
anthropicExtraUsage?: boolean;
|
||||||
credential?: AuthStorage;
|
|
||||||
}): SubscriptionSession {
|
}): SubscriptionSession {
|
||||||
const authStorage = options.credential ?? AuthStorage.inMemory();
|
|
||||||
return {
|
return {
|
||||||
model: options.provider === undefined ? undefined : anthropicModel(options.provider),
|
model: options.provider === undefined ? undefined : anthropicModel(options.provider),
|
||||||
settingsManager: {
|
settingsManager: {
|
||||||
getWarnings: () => (options.anthropicExtraUsage === undefined ? {} : { anthropicExtraUsage: options.anthropicExtraUsage }),
|
getWarnings: () => (options.anthropicExtraUsage === undefined ? {} : { anthropicExtraUsage: options.anthropicExtraUsage }),
|
||||||
setWarnings: () => undefined,
|
setWarnings: () => undefined,
|
||||||
},
|
},
|
||||||
modelRegistry: ModelRegistry.create(authStorage),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function anthropicAuth(credential: { type: "oauth" } | { type: "api_key"; key: string }): AuthStorage {
|
const tempDirs: string[] = [];
|
||||||
const authStorage = AuthStorage.inMemory();
|
|
||||||
if (credential.type === "oauth") {
|
afterEach(async () => {
|
||||||
authStorage.set("anthropic", { type: "oauth", access: "a", refresh: "r", expires: Date.now() + 3_600_000 });
|
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||||
} else {
|
});
|
||||||
authStorage.set("anthropic", { type: "api_key", key: credential.key });
|
|
||||||
}
|
/**
|
||||||
return authStorage;
|
* 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", () => {
|
describe("anthropicSubscriptionWarning", () => {
|
||||||
it("warns with the verbatim SDK wording for a stored oauth credential", () => {
|
it("warns with the verbatim SDK wording for a stored oauth credential", async () => {
|
||||||
expect(anthropicSubscriptionWarning(subscriptionSession({
|
expect(anthropicSubscriptionWarning(
|
||||||
provider: "anthropic",
|
subscriptionSession({ provider: "anthropic" }),
|
||||||
credential: anthropicAuth({ type: "oauth" }),
|
await anthropicAuthPath({ type: "oauth" }),
|
||||||
}))).toEqual({
|
)).toEqual({
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
message: ANTHROPIC_SUBSCRIPTION_AUTH_WARNING,
|
message: ANTHROPIC_SUBSCRIPTION_AUTH_WARNING,
|
||||||
source: "anthropic",
|
source: "anthropic",
|
||||||
@@ -131,37 +143,41 @@ describe("anthropicSubscriptionWarning", () => {
|
|||||||
} satisfies SessionWarning);
|
} satisfies SessionWarning);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("warns for an sk-ant-oat subscription API key", () => {
|
it("warns for an sk-ant-oat subscription API key", async () => {
|
||||||
expect(anthropicSubscriptionWarning(subscriptionSession({
|
expect(anthropicSubscriptionWarning(
|
||||||
provider: "anthropic",
|
subscriptionSession({ provider: "anthropic" }),
|
||||||
credential: anthropicAuth({ type: "api_key", key: "sk-ant-oat-abc123" }),
|
await anthropicAuthPath({ type: "api_key", key: "sk-ant-oat-abc123" }),
|
||||||
}))?.message).toBe(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING);
|
)?.message).toBe(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not warn for a standard anthropic API key", () => {
|
it("does not warn for a standard anthropic API key", async () => {
|
||||||
expect(anthropicSubscriptionWarning(subscriptionSession({
|
expect(anthropicSubscriptionWarning(
|
||||||
provider: "anthropic",
|
subscriptionSession({ provider: "anthropic" }),
|
||||||
credential: anthropicAuth({ type: "api_key", key: "sk-ant-api-abc123" }),
|
await anthropicAuthPath({ type: "api_key", key: "sk-ant-api-abc123" }),
|
||||||
}))).toBeUndefined();
|
)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("respects the anthropicExtraUsage suppression gate", () => {
|
it("respects the anthropicExtraUsage suppression gate", async () => {
|
||||||
expect(anthropicSubscriptionWarning(subscriptionSession({
|
expect(anthropicSubscriptionWarning(
|
||||||
provider: "anthropic",
|
subscriptionSession({ provider: "anthropic", anthropicExtraUsage: false }),
|
||||||
anthropicExtraUsage: false,
|
await anthropicAuthPath({ type: "oauth" }),
|
||||||
credential: anthropicAuth({ type: "oauth" }),
|
)).toBeUndefined();
|
||||||
}))).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not warn when the active provider is not anthropic", () => {
|
it("does not warn when the active provider is not anthropic", async () => {
|
||||||
expect(anthropicSubscriptionWarning(subscriptionSession({
|
expect(anthropicSubscriptionWarning(
|
||||||
provider: "openai",
|
subscriptionSession({ provider: "openai" }),
|
||||||
credential: anthropicAuth({ type: "oauth" }),
|
await anthropicAuthPath({ type: "oauth" }),
|
||||||
}))).toBeUndefined();
|
)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not warn when no anthropic credential is stored", () => {
|
it("does not warn when no anthropic credential is stored", async () => {
|
||||||
expect(anthropicSubscriptionWarning(subscriptionSession({ provider: "anthropic" }))).toBeUndefined();
|
const dir = await mkdtemp(join(tmpdir(), "pi-web-warnings-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
expect(anthropicSubscriptionWarning(
|
||||||
|
subscriptionSession({ provider: "anthropic" }),
|
||||||
|
join(dir, "auth.json"),
|
||||||
|
)).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionStatus, SessionStreamSnapshot } from "../../shared/apiTypes.js";
|
||||||
import { SessionEventHub } from "../realtime/sessionEventHub.js";
|
import { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||||
import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js";
|
import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js";
|
||||||
|
import { testModelRuntime } from "./piSessionService.testSupport.js";
|
||||||
import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js";
|
import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js";
|
||||||
import { registerSessionRoutes } from "./sessionRoutes.js";
|
import { registerSessionRoutes } from "./sessionRoutes.js";
|
||||||
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
|
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
|
||||||
@@ -20,7 +21,7 @@ beforeEach(async () => {
|
|||||||
await app.register(fastifyWebsocket);
|
await app.register(fastifyWebsocket);
|
||||||
sessionManager = new RejectingSessionManager();
|
sessionManager = new RejectingSessionManager();
|
||||||
const eventHub = new SessionEventHub();
|
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);
|
registerSessionRoutes(app, service, eventHub);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+17
-2
@@ -373,6 +373,8 @@ export interface AuthProviderOption {
|
|||||||
name: string;
|
name: string;
|
||||||
authType: AuthType;
|
authType: AuthType;
|
||||||
status: AuthProviderStatus;
|
status: AuthProviderStatus;
|
||||||
|
/** Additive hint: use the generic AuthInteraction transport instead of the legacy one-secret form. */
|
||||||
|
loginFlow?: "interactive";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthProvidersResponse {
|
export interface AuthProvidersResponse {
|
||||||
@@ -384,10 +386,23 @@ export interface OAuthFlowState {
|
|||||||
providerId: string;
|
providerId: string;
|
||||||
providerName: string;
|
providerName: string;
|
||||||
status: "running" | "complete" | "error" | "cancelled";
|
status: "running" | "complete" | "error" | "cancelled";
|
||||||
auth?: { url: string; instructions?: string };
|
auth?: {
|
||||||
prompt?: { requestId: string; message: string; placeholder?: string; allowEmpty?: boolean; kind: "prompt" | "manual" };
|
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[] };
|
select?: { requestId: string; message: string; options: CommandOption[] };
|
||||||
progress: string[];
|
progress: string[];
|
||||||
|
info?: { message: string; links?: { url: string; label?: string }[] }[];
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export const FEDERATED_HTTP_ROUTES = [
|
|||||||
{ method: "POST", path: "/sessions/:sessionId/detach-parent" },
|
{ method: "POST", path: "/sessions/:sessionId/detach-parent" },
|
||||||
{ method: "GET", path: "/auth/providers" },
|
{ method: "GET", path: "/auth/providers" },
|
||||||
{ method: "POST", path: "/auth/api-key" },
|
{ method: "POST", path: "/auth/api-key" },
|
||||||
|
{ method: "POST", path: "/auth/api-key/interactive" },
|
||||||
{ method: "POST", path: "/auth/logout" },
|
{ method: "POST", path: "/auth/logout" },
|
||||||
{ method: "POST", path: "/auth/oauth" },
|
{ method: "POST", path: "/auth/oauth" },
|
||||||
{ method: "GET", path: "/auth/oauth/:flowId" },
|
{ method: "GET", path: "/auth/oauth/:flowId" },
|
||||||
|
|||||||
Reference in New Issue
Block a user