diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 828c30a..78adf7f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,6 +18,13 @@ jobs:
steps:
- uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+
+ - name: Test shell session credential isolation
+ run: node --test test/*.test.cjs
+
- uses: actions/setup-go@v5
with:
go-version: '1.26'
diff --git a/README.md b/README.md
index 7795e3b..cabc678 100644
--- a/README.md
+++ b/README.md
@@ -138,6 +138,9 @@ adminui.Handler(adminui.ShellOptions{
Use `AuthModeSession` when the host authenticates admin APIs with same-origin
cookies. The default bearer-token mode preserves the standalone plugin shell
behavior.
+Session mode ignores stored bearer tokens and does not forward them to APIs or
+embedded tools, or change bearer-token storage. The host must authenticate and
+authorize the shell, each contributed tool, and every API request.
## Host Conformance
diff --git a/internal/ui_dist/index.html b/internal/ui_dist/index.html
index 8e9f7dc..d2e768e 100644
--- a/internal/ui_dist/index.html
+++ b/internal/ui_dist/index.html
@@ -372,6 +372,7 @@
Management pages
let currentGrantedScopes = [];
function storedToken() {
+ if (!requiresBearerToken) return '';
try {
return window.localStorage.getItem(tokenStorageKey) || '';
} catch (_) {
@@ -380,6 +381,7 @@ Management pages
}
function storeToken(token) {
+ if (!requiresBearerToken) return;
try {
window.localStorage.setItem(tokenStorageKey, token);
} catch (_) {
@@ -388,6 +390,7 @@ Management pages
}
function clearToken() {
+ if (!requiresBearerToken) return;
try {
window.localStorage.removeItem(tokenStorageKey);
} catch (_) {
diff --git a/test/shell-session.test.cjs b/test/shell-session.test.cjs
new file mode 100644
index 0000000..a5c0adb
--- /dev/null
+++ b/test/shell-session.test.cjs
@@ -0,0 +1,48 @@
+const assert = require('node:assert/strict')
+const fs = require('node:fs')
+const path = require('node:path')
+const test = require('node:test')
+const vm = require('node:vm')
+
+const html = fs.readFileSync(path.join(__dirname, '../internal/ui_dist/index.html'), 'utf8')
+// Execute the shipped storage/header functions. This checks browser effects,
+// rather than asserting the implementation contains a particular guard string.
+const start = html.indexOf(' function storedToken() {')
+const end = html.indexOf(' function scopeStrings(', start)
+assert.ok(start >= 0 && end > start)
+const functions = html.slice(start, end)
+
+function shell(mode, stored = 'fixture-stale-bearer') {
+ const effects = []
+ const storage = new Map([['workflow.admin.token', stored]])
+ const context = vm.createContext({
+ window: { localStorage: {
+ getItem(key) { effects.push('read'); return storage.get(key) },
+ setItem(key, value) { effects.push('write'); storage.set(key, value) },
+ removeItem(key) { effects.push('remove'); storage.delete(key) },
+ } },
+ })
+ vm.runInContext(`const requiresBearerToken = ${JSON.stringify(mode)} !== 'session'; const tokenStorageKey = 'workflow.admin.token';\n${functions}`, context)
+ return { run: code => vm.runInContext(code, context), effects, storage }
+}
+
+test('session APIs and embedded bridges cannot receive a stale bearer token', () => {
+ const session = shell('session')
+ assert.equal(session.run('storedToken()'), '')
+ assert.equal(session.run('authHeaders({accept: "application/json"}).Authorization'), undefined)
+ assert.equal(session.run('authHeaders({accept: "application/json"}).accept'), 'application/json')
+ session.run('storeToken("fixture-new"); clearToken()')
+ assert.deepEqual(session.effects, [])
+ assert.equal(session.storage.get('workflow.admin.token'), 'fixture-stale-bearer')
+})
+
+test('bearer and unknown modes retain the protected bearer behavior', () => {
+ for (const mode of ['bearer', 'invalid', undefined]) {
+ const bearer = shell(mode)
+ assert.equal(bearer.run('authHeaders().Authorization'), 'Bearer fixture-stale-bearer')
+ bearer.run('storeToken("fixture-new")')
+ assert.equal(bearer.run('storedToken()'), 'fixture-new')
+ bearer.run('clearToken()')
+ assert.equal(bearer.storage.size, 0)
+ }
+})