From b93c892b84be68062ebb7ac4700c3e0f73f44cb1 Mon Sep 17 00:00:00 2001 From: Jose Antonio Alatorre Date: Tue, 3 Nov 2026 23:49:06 +0400 Subject: [PATCH] Show when the MainSequence login ends, with devtools 0.3.15 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit devtools 0.3.15 reports when the saved login ends (`sessionEndsAt` in auth-status, the refresh token's expiry; renewing does not move it) and reports the account signed out offline once it has passed. From a day before the end, the panel header's account dot turns amber and the header says "MainSequence session ends — sign in again (/ms login)". Once it has passed, the header shows Session ended with when it ended. While the panel stays open, a timer redraws the header at the warning and at the end. The account in /ms login, auth-status, refresh-token and status results gives the same warning and end, and otherwise names when it ends. FORBIDDEN (HTTP 403, new in devtools 0.3.15) stays an ordinary error in the panel, the discovery layer and /ms output: it keeps the panel's context and offers no sign-in. Only AUTH_REJECTED and NOT_AUTHENTICATED end the view. The devtools pin moves to 0.3.15 exactly; the lockfile integrity is the registry's. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++++++ docs/agents-tools-panel.md | 17 +++++++++++++-- package-lock.json | 8 +++---- package.json | 2 +- src/hosts/codexPresentation.ts | 26 +++++++++++++++++++++- src/hosts/discovery.ts | 4 +++- src/hosts/panel/panel.html | 32 ++++++++++++++++++++++----- tests/codex.test.cjs | 24 ++++++++++++++++++++ tests/panel-discovery.test.cjs | 32 ++++++++++++++++++++++++++- tests/panel-ui.test.cjs | 40 ++++++++++++++++++++++++++++++++-- 10 files changed, 174 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index e34c066..6321158 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,13 @@ Activity** and **Settings**. From chat, `/ms login --backend https://api.main-sequence.app` does the same sign-in, and `/ms auth-status --check` verifies it. +A sign-in lasts a fixed time that the backend sets (five days by default); renewing the +session does not extend it. From a day before it ends, the panel header and the account in +`/ms` results say **MainSequence session ends** with the local time and point to +`/ms login`. Once it has ended, they say when and ask you to sign in again. A refused +permission (HTTP 403) shows its message as an ordinary error: signing in again does not +change it. + ## What you can do - **Agents.** Browse the environment's agents with their descriptions, skills and example diff --git a/docs/agents-tools-panel.md b/docs/agents-tools-panel.md index 0e13b8a..1803635 100644 --- a/docs/agents-tools-panel.md +++ b/docs/agents-tools-panel.md @@ -8,7 +8,7 @@ stay unchanged. No probe or custom Codex build is shipped. | Surface | Implemented behavior | | --- | --- | -| Header/navigation | MainSequence emblem, shared account, environment, Activity, Settings; Home/Code/Projects/Agents/Tools; compact rows and grouped details; host theme variables, neutral fallbacks and reduced-motion icons | +| Header/navigation | MainSequence emblem, shared account with when its login ends, environment, Activity, Settings; Home/Code/Projects/Agents/Tools; compact rows and grouped details; host theme variables, neutral fallbacks and reduced-motion icons | | Agents | Search/pages, real icons, defaults, descriptions, skills/examples; Talk prepares a stable session without sending; new conversations; drafts survive browsing; direct messages and task mode | | Native context/recovery | Immutable request saved before native submission, at-most-once dispatch, durable receipts, same-ID recovery, uncertain sends blocked; native MCP results and labelled replies remain conversation context | | Agent tasks | Accepted task receipt, get/wait/cancel, continue an owned saved task, outputs in native context; no task read/recovery resends the original message | @@ -63,7 +63,9 @@ known in another environment names that environment; an unknown release says whi (`mainsequence_view connections` or `mainsequence_discover tools`) must come first; an omitted `arguments` object on `mainsequence_call_tool` is an empty object; release ids match regardless of case. `CONTEXT_CHANGED` carries its cause (`AUTH_REJECTED: The MCP endpoint -returned HTTP 401.`). +returned HTTP 401.`). Only `AUTH_REJECTED` and `NOT_AUTHENTICATED` end the view: `FORBIDDEN` +(HTTP 403, a permission the signed-in account lacks) keeps the context, and the panel, the +tools and `/ms` show its message as an ordinary error with no sign-in prompt. Agent icons come inline from the platform's agent directory (`include_icons: true`); the panel does not request individual icon URLs, and a missing or invalid image renders the @@ -129,6 +131,17 @@ dispatch are never retried; after a process dies during send, or a transport res ambiguous, the request is **uncertain** and resend is blocked. A new request must never be used to bypass an uncertain one. +Login end: a saved login ends at a fixed moment, its refresh token's expiry, which +devtools reports as `sessionEndsAt` in `auth-status`; renewing does not move it. +`mainsequence_open` returns it with the account. From a day before, the header's account +dot turns amber and the header says "MainSequence session ends today at 14:30 — sign in +again (/ms login)", in local time. Once it has passed, devtools reports the account signed +out without contacting the backend, and the header shows **Session ended** with when it +ended. While the panel stays open, a timer redraws the header at the warning and at the end; +the next request then refuses the old view and asks for a refresh. The account in +`/ms login`, `/ms auth-status`, `/ms refresh-token` and `/ms status` results gives the same +warning and end, and otherwise names when the login ends. + Plugin version and update: the panel header always shows the running plugin version. When the panel opens, the server compares it with the `mainsequence` catalog on this repository's default branch and with the copy Codex has installed (`codex plugin list diff --git a/package-lock.json b/package-lock.json index bf179f2..489c696 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.7.12", "license": "MIT", "dependencies": { - "@dev-mainsequence/devtools": "0.3.14" + "@dev-mainsequence/devtools": "0.3.15" }, "bin": { "ms-devtools": "dist/runner.js" @@ -23,9 +23,9 @@ } }, "node_modules/@dev-mainsequence/devtools": { - "version": "0.3.14", - "resolved": "https://registry.npmjs.org/@dev-mainsequence/devtools/-/devtools-0.3.14.tgz", - "integrity": "sha512-H7VvkHOMemQV8YjoZVlwLTK7ltSnPPwW5QQyVLDxUWIN58bwgMOqJSBLzHa2iElNq2CD7TJnwqRzomAS3Qu4dg==", + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@dev-mainsequence/devtools/-/devtools-0.3.15.tgz", + "integrity": "sha512-YBU65IB57QMz3A/fcZCOaSSgdT7wND1iCdLmz0cItu57qsjm7AmUxYbXKw5DcfucJtT//IqxZDMK1zn8cj1kWQ==", "license": "MIT", "bin": { "ms-devtools": "dist/runner.js" diff --git a/package.json b/package.json index a9eb941..2cc8de4 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "check:docs": "node scripts/check-docs.cjs" }, "dependencies": { - "@dev-mainsequence/devtools": "0.3.14" + "@dev-mainsequence/devtools": "0.3.15" }, "devDependencies": { "@types/node": "18.19.130", diff --git a/src/hosts/codexPresentation.ts b/src/hosts/codexPresentation.ts index 038f04a..0cfe446 100644 --- a/src/hosts/codexPresentation.ts +++ b/src/hosts/codexPresentation.ts @@ -87,14 +87,37 @@ function fields(value: unknown, indent = ''): string[] { return lines; } +// A saved login ends at a fixed moment, its refresh token's expiry, which devtools reports as +// `sessionEndsAt`; renewing does not move it. Within a day of it the account warns, and once it +// has passed it says when the login ended. Times are the machine's local time. +const LOGIN_WARN_MS = 86_400_000; +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; +const two = (value: number): string => String(value).padStart(2, '0'); +/** "today at 12:30", "tomorrow at 09:05", "yesterday at 18:00" or "on 30 Oct at 07:16". */ +function loginWhen(at: number, now: number): string { + const date = new Date(at), day = (value: number) => new Date(value).toDateString(); + const named = { [day(now)]: 'today', [day(now + 86_400_000)]: 'tomorrow', [day(now - 86_400_000)]: 'yesterday' }[day(at)]; + return `${named || `on ${date.getDate()} ${MONTHS[date.getMonth()]}`} at ${two(date.getHours())}:${two(date.getMinutes())}`; +} +function loginEnd(session: Record, now = Date.now()): { notice: string; line: string } { + const at = typeof session.sessionEndsAt === 'string' ? Date.parse(session.sessionEndsAt) : NaN; + if (!Number.isFinite(at)) return { notice: '', line: '' }; + const when = loginWhen(at, now); + if (at <= now) return { notice: `MainSequence session ended ${when} — sign in again (/ms login)`, line: '' }; + if (at - now <= LOGIN_WARN_MS) return { notice: `MainSequence session ends ${when} — sign in again (/ms login)`, line: '' }; + return { notice: '', line: `Session ends ${when}` }; +} + function account(value: unknown): string[] { - const session = record(value); + const session = record(value), end = loginEnd(session); const username = typeof session.username === 'string' && session.username ? ` as ${singleLine(session.username)}` : ''; const lines = [session.authenticated ? `Signed in${username}` : 'Not signed in']; + if (end.notice) lines.push(end.notice); if (session.backend) lines.push(`Backend: ${singleLine(session.backend)}`); if (session.source === 'machine') lines.push('Login shared with your SDKs'); else if (session.source === 'environment') lines.push('Using the login from your environment'); else if (session.source === 'host') lines.push('Using the editor login'); + if (end.line) lines.push(end.line); if (session.expiresAt) lines.push(`Expires: ${singleLine(session.expiresAt)}`); if (session.checked) lines.push('Verified with the backend'); return lines; @@ -120,6 +143,7 @@ export function displayCodexResult(result: ActionResult): string { sections.push(result.summary); const code = result.error?.code; if (code === 'INVALID_REQUEST') sections.push('For commands and examples: /ms --help'); + // FORBIDDEN (HTTP 403) is a refused permission for a signed-in account, so it offers no sign-in. else if (code === 'NOT_AUTHENTICATED' || code === 'AUTH_REJECTED') sections.push('Sign in again: /ms login'); } else if (result.action === 'repositories' && Array.isArray(result.data)) sections.push(repositories(result.data)); else if (['login', 'refresh-token', 'auth-status'].includes(result.action)) sections.push(account(result.data).join('\n')); diff --git a/src/hosts/discovery.ts b/src/hosts/discovery.ts index a5b5c31..c513a9d 100644 --- a/src/hosts/discovery.ts +++ b/src/hosts/discovery.ts @@ -21,7 +21,7 @@ function success(result: ActionResult): T { } function account(value: SessionStatus) { return { backend: text(value.backend), authenticated: value.authenticated === true, username: text(value.username, 300), - source: text(value.source, 40), checked: value.checked === true, expiresAt: text(value.expiresAt, 100) }; + source: text(value.source, 40), checked: value.checked === true, expiresAt: text(value.expiresAt, 100), sessionEndsAt: text(value.sessionEndsAt, 100) }; } const environmentLabel = (context: Context, uid: string) => `${context.environments.get(uid) || 'environment'} (${uid})`; /** A release the context has seen, in this environment. Says where it was seen instead when it belongs elsewhere. */ @@ -91,6 +91,8 @@ export function createDiscovery(dependencies?: { let answer; try { answer = await run(context, environmentUid); } catch (error) { + // A sign-in failure ends this view. FORBIDDEN (HTTP 403) is a refused permission for a signed-in + // account: it keeps the context and reaches the caller as an ordinary error. if (error instanceof DiscoveryError && ['AUTH_REJECTED', 'NOT_AUTHENTICATED'].includes(error.code)) { contexts.delete(String(args.contextId)); throw new DiscoveryError('CONTEXT_CHANGED', `The account or session changed (${error.code}: ${error.message}). Refresh MainSequence and retry once.`); diff --git a/src/hosts/panel/panel.html b/src/hosts/panel/panel.html index b1969c1..d00d3f6 100644 --- a/src/hosts/panel/panel.html +++ b/src/hosts/panel/panel.html @@ -4,7 +4,7 @@ :root{--brand-image: none;color-scheme:light dark;font-family:var(--font-sans,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif);font-size:var(--font-text-sm-size,13px);line-height:1.5;color:var(--color-text-primary,light-dark(#242424,#eee));background:var(--color-background-primary,light-dark(#fff,#181818));--muted:var(--color-text-secondary,light-dark(#666,#aaa));--border:var(--color-border-secondary,light-dark(#e5e5e5,#353535));--surface:var(--color-background-secondary,light-dark(#f5f5f5,#242424));--ink:var(--color-text-primary,light-dark(#242424,#eee));--paper:var(--color-background-primary,light-dark(#fff,#181818))}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit;color:inherit}button{border:1px solid var(--border);border-radius:var(--border-radius-md,7px);background:transparent;padding:5px 10px;min-height:30px;cursor:pointer;white-space:nowrap}button:hover{background:var(--surface)}button:disabled{opacity:.45;cursor:default}button.primary,nav button[aria-selected=true]{background:var(--ink);color:var(--paper);border-color:transparent}button.primary:hover,nav button[aria-selected=true]:hover{opacity:.85}button.plain{border-color:transparent;padding:4px 6px}button.name{text-align:left;white-space:normal;padding:0;border:0;min-height:24px;font-weight:600;background:none}button.name:hover{text-decoration:underline;text-underline-offset:3px}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible{outline:2px solid var(--ink);outline-offset:3px}input,select,textarea{border:1px solid var(--border);border-radius:var(--border-radius-md,7px);background:transparent;padding:6px 9px;min-height:32px;min-width:0}select{max-width:100%}input{flex:1}textarea{width:100%;resize:vertical;min-height:75px;line-height:1.5}label,small,.caption{font-size:12px;color:var(--muted)}h1,h2,h3{overflow-wrap:anywhere}h1{font-size:15px;font-weight:600;letter-spacing:-.2px;margin:0}h2{font-size:15px;font-weight:600;margin:0}h3{font-size:13px;margin:0;font-weight:600}p{margin:4px 0;overflow-wrap:anywhere}.muted{color:var(--muted)}.stack{display:flex;flex-direction:column;gap:10px}.row{display:flex;align-items:center;gap:8px;min-width:0}.wrap{flex-wrap:wrap}.between{justify-content:space-between}.grow{flex:1;min-width:0}.brand{gap:8px}.brand-mark{width:23px;height:23px;background:var(--ink);mask:var(--brand-image) center/contain no-repeat;-webkit-mask:var(--brand-image) center/contain no-repeat}.account-row{gap:8px;flex-wrap:wrap;margin-top:10px}.account-row select{max-width:190px;font-size:12px;min-height:28px;padding:3px 7px}.account-name{font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}.account-name:before{content:'●';font-size:7px;vertical-align:2px;margin-right:6px}.shell{max-width:920px;margin:auto;min-height:100vh;display:flex;flex-direction:column}.header{position:sticky;top:0;z-index:2;background:var(--paper);padding:16px 16px 0}nav{display:flex;gap:3px;flex-wrap:wrap;padding:12px 0;border-bottom:1px solid var(--border)}nav button{border:0;padding:5px 10px;color:var(--muted)}.content{padding:16px;flex:1}.toolbar{display:flex;align-items:center;gap:8px;margin-bottom:10px}.toolbar input{width:100%}.notice:empty{display:none}.notice{margin:0 0 12px;color:var(--muted);font-size:12px}.item{padding:10px 0;border-bottom:1px solid var(--border);min-width:0}.item:last-child{border-bottom:0}.item .caption{margin-top:1px}.agent-icon{width:22px;height:22px;flex-shrink:0;object-fit:contain;align-self:flex-start;margin-top:2px}.agent-icon.mask{background:var(--ink);mask:var(--agent-image) center/contain no-repeat;-webkit-mask:var(--agent-image) center/contain no-repeat}.agent-icon.large{width:34px;height:34px}.agent-placeholder{font-size:22px;line-height:22px;color:var(--muted);text-align:center}.detail-heading{flex-wrap:wrap;margin:12px 0 16px}.detail-heading h2{font-size:18px}.detail-section{margin:18px 0}.detail-section h3{margin-bottom:8px}.facts{display:grid;grid-template-columns:auto 1fr;gap:5px 18px;margin:12px 0;font-size:12px}.facts dt{color:var(--muted)}.facts dd{margin:0;text-align:right;overflow-wrap:anywhere}.card{border:1px solid var(--border);border-radius:var(--border-radius-lg,10px);padding:12px;margin-bottom:12px}.card>h2,.card>h3{margin-bottom:9px}.empty{padding:26px 4px}.empty h2{margin-bottom:5px}.pager{display:flex;justify-content:space-between;align-items:center;margin-top:12px;font-size:12px}.pager button{border:0;color:var(--muted)}details{margin-top:8px}summary{cursor:pointer;color:var(--muted);font-size:12px}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.5 var(--font-mono,ui-monospace,monospace);padding:10px;background:var(--surface);border-radius:7px;max-height:280px;overflow:auto}code{font-family:var(--font-mono,ui-monospace,monospace);font-size:.92em}.markdown p{white-space:pre-wrap;margin:8px 0}.markdown ul{padding-left:20px;margin:8px 0}.markdown h3{margin-top:14px}.example{white-space:normal;text-align:left;width:100%;margin-top:6px}.composer{position:sticky;bottom:0;background:var(--paper);border-top:1px solid var(--border);padding:12px 16px;z-index:1}.composer .row{margin-bottom:8px}.composer>.row:last-child{flex-wrap:wrap;gap:6px}.composer>.row:last-child>.row{margin:0 0 0 auto}.composer textarea{border:0;background:var(--surface);margin-bottom:8px}.composer .caption{margin:2px 0 0}.selected-label{font-size:11px;color:var(--muted)}#delivery-notice{margin:0;font-size:12px}#delivery-notice:not(:empty){padding:8px 16px;border-top:1px solid var(--border)}.receipt{white-space:pre-wrap;overflow-wrap:anywhere;border-left:2px solid var(--border);padding-left:10px;margin-top:8px}.review{padding:12px;border:1px solid var(--ink);border-radius:8px;margin:12px 0}.result-label{font-size:12px;color:var(--muted)}footer{padding:0 16px 12px;color:var(--muted);font-size:11px}#deliveries{padding:0 16px 16px}#deliveries>summary{padding:8px 0}#account-issue:empty{display:none}#account-issue{font-size:12px;padding-top:8px}#confirmation:empty{display:none}[hidden]{display:none!important}@media(max-width:400px){.header{padding:12px 12px 0}.content{padding:12px}.composer{padding:12px}nav{gap:0}nav button{padding:5px 8px}.account-row select{max-width:160px}.toolbar{gap:4px}.toolbar button{padding:5px 7px}.facts{gap:5px 10px}}input[type=checkbox]{flex:none;width:16px;min-height:16px;height:16px;margin:0;accent-color:var(--ink)}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}} :root{font-size:var(--font-text-sm-size,14px)}.header{padding:20px 20px 0}.content{padding:8px 20px 20px}.brand h1{font-size:16px}.brand-mark{width:24px;height:24px}.account-row{margin-top:12px;gap:8px}.account-row select,.account-name{font-size:13px}.account-row select{width:108px}.account-row{gap:6px}.utility{margin-left:auto;gap:4px}nav{border-bottom:0;gap:4px;padding:10px 0 0}.item{border:0;padding:9px 0}.item h3,button.name{font-size:14px}.caption,small{font-size:13px}.field{display:flex;flex-direction:column;gap:5px;margin:10px 0}.field input,.field select{width:100%}.selected{background:var(--surface)}.card .row{flex-wrap:wrap}.card{margin:10px 0 14px;padding:14px}.detail-heading{margin:8px 0}.detail-heading h2{font-size:16px}#results>h2{margin:10px 0}#results>h3{margin-top:16px}.toolbar{margin:0 0 6px}#footer{display:none}.shell{max-width:none}.account-name{overflow-wrap:anywhere;white-space:normal}.review p{white-space:pre-wrap} -.account-name[data-status=connected]:before{color:var(--color-text-success,light-dark(#16803d,#40c977))}.account-name[data-status=error]:before{color:var(--color-text-danger,light-dark(#b42318,#ff6764))}.account-row select{width:auto;min-width:132px;max-width:220px} +.account-name[data-status=connected]:before{color:var(--color-text-success,light-dark(#16803d,#40c977))}.account-name[data-status=error]:before{color:var(--color-text-danger,light-dark(#b42318,#ff6764))}.account-name[data-status=warning]:before{color:var(--color-text-warning,light-dark(#9a6700,#e3b341))}#login-notice:empty{display:none}#login-notice{font-size:12px;padding-top:8px;color:var(--color-text-warning,light-dark(#9a6700,#e3b341))}.account-row select{width:auto;min-width:132px;max-width:220px} @media(max-width:400px){.header{padding:14px 12px 0}.content{padding:8px 12px 16px}.account-row{gap:4px}.utility{margin-left:0}.account-name{font-size:12px}.account-row select{max-width:180px}nav button{padding:5px 7px}.toolbar{flex-wrap:wrap}button{max-width:100%;white-space:normal}.row.between{flex-wrap:wrap}.card{padding:10px}} .brand{flex-wrap:wrap;row-gap:2px}.version{color:var(--muted);font-size:12px}.release{gap:6px;font-size:12px;white-space:normal}.release button{min-height:24px;padding:2px 8px;font-size:12px}.release .ok{color:var(--color-text-success,light-dark(#16803d,#40c977))}.release .warn{color:var(--color-text-warning,light-dark(#9a6700,#e3b341))}.plugin-help{margin:10px 0 0} @@ -13,6 +13,7 @@

MainSequence

+

@@ -50,6 +51,26 @@ // after the person confirms, updates through Codex's own plugin commands; the new version loads in a new chat. const UPDATE_COMMANDS = ['codex plugin marketplace upgrade mainsequence', 'codex plugin add mainsequence@mainsequence']; let pluginState = null, pluginBusy = ''; +// A saved login ends at a fixed moment, its refresh token's expiry, which devtools reports as sessionEndsAt; renewing +// does not move it. The header warns a day before and says when it ended from then on, in local time. While the panel +// stays open a timer redraws the header at the warning and at the end; the next request then refuses the old view. +const LOGIN_WARN_MS = 86400000, MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; +let loginTimer = null; +function loginWhen(at, now) { + const date = new Date(at), day = value => new Date(value).toDateString(), two = value => String(value).padStart(2, '0'); + const named = { [day(now)]: 'today', [day(now + 86400000)]: 'tomorrow', [day(now - 86400000)]: 'yesterday' }[day(at)]; + return (named || 'on ' + date.getDate() + ' ' + MONTHS[date.getMonth()]) + ' at ' + two(date.getHours()) + ':' + two(date.getMinutes()); +} +function loginNotice(account, now) { const at = Date.parse(account?.sessionEndsAt); return Number.isFinite(at) ? { kind: at <= now ? 'ended' : at - now <= LOGIN_WARN_MS ? 'soon' : 'active', at, when: loginWhen(at, now) } : null; } +function renderAccount(state) { + clearTimeout(loginTimer); loginTimer = null; + const a = state.account, now = Date.now(), notice = loginNotice(a, now), ended = notice?.kind === 'ended', soon = a.authenticated && notice?.kind === 'soon'; + accountIndicator(ended ? 'Session ended' : a.authenticated ? (a.username || 'Signed in') : 'Not signed in', ended ? 'error' : soon ? 'warning' : a.authenticated && a.checked ? 'connected' : state.issue ? 'error' : 'pending'); + $('account-issue').textContent = state.issue?.message || (!a.authenticated && !ended ? 'Sign in with /ms login, then refresh.' : ''); + $('login-notice').textContent = ended ? 'MainSequence session ended ' + notice.when + ' — sign in again (/ms login), then refresh.' : soon ? 'MainSequence session ends ' + notice.when + ' — sign in again (/ms login)' : ''; + const next = notice && !ended ? (soon ? notice.at : notice.at - LOGIN_WARN_MS) - now : 0; + if (connected && next > 0) loginTimer = setTimeout(() => { if (snapshot === state) renderAccount(state); }, Math.min(next, 2147483647)); +} function watchIcons(run, load) { clearTimeout(iconWatch); iconWatch = setTimeout(async () => { @@ -73,7 +94,7 @@ function rememberEnvironment() {try {const key=environmentKey(snapshot),uid=$('environment').value;if(key && snapshot.environments.some(e=>e.uid===uid))localStorage.setItem(key,uid);} catch {}} function accountIndicator(label,status='pending') { const account=$('account');account.textContent=label;account.setAttribute('data-status',status); - const description=status==='connected'?'Connected':status==='error'?'Connection needs attention':'Connection not verified'; + const description=status==='connected'?'Connected':status==='error'?'Connection needs attention':status==='warning'?'Session ends soon':'Connection not verified'; account.setAttribute('title',description);account.setAttribute('aria-label',description+': '+label); } function rpc(method, params = {}) { @@ -167,11 +188,10 @@ clearComposer();assetCache.clear();++deliveryGeneration;$('delivery-results').replaceChildren();$('deliveries').hidden=true; const run=++generation,previous=$('environment').value,previousKey=environmentKey(snapshot); busy=true;snapshot=null;clearResults();controls();accountIndicator('Checking your session…'); - $('account-issue').textContent='';$('notice').textContent=''; + clearTimeout(loginTimer);$('account-issue').textContent='';$('login-notice').textContent='';$('notice').textContent=''; try { const state=await call('mainsequence_open');if(run!==generation)return;snapshot=state; - accountIndicator(state.account.authenticated?(state.account.username||'Signed in'):'Not signed in',state.account.authenticated&&state.account.checked?'connected':state.issue?'error':'pending'); - $('account-issue').textContent=state.issue?.message||(!state.account.authenticated?'Sign in with /ms login, then refresh.':''); + renderAccount(state); const placeholder=node('option',state.environments.length?'Choose environment ('+state.environments.length+')':state.issue?'Environments unavailable':'No environments available');placeholder.value=''; $('environment').replaceChildren(placeholder,...state.environments.map(env=>{const option=node('option',env.name);option.value=env.uid;return option;})); const candidates=[previousKey===environmentKey(state)?previous:null,state.selectedEnvironmentUid,rememberedEnvironment(state),state.environments.length===1?state.environments[0].uid:null]; @@ -307,7 +327,7 @@ $('close-composer').onclick = () => { clearComposer(); controls(); }; $('refresh-deliveries').onclick = loadDeliveries; -function teardown() { ++deliveryGeneration; ++generation; connected = false; clearTimeout(iconWatch); for (const p of pending.values()) { clearTimeout(p.timeout); p.reject(new Error('The panel closed.')); } pending.clear(); controls(); } +function teardown() { ++deliveryGeneration; ++generation; connected = false; clearTimeout(iconWatch); clearTimeout(loginTimer); for (const p of pending.values()) { clearTimeout(p.timeout); p.reject(new Error('The panel closed.')); } pending.clear(); controls(); } addEventListener('message', event => { if (event.source !== parent || event.data?.jsonrpc !== '2.0') return; const message = event.data, p = pending.get(message.id); diff --git a/tests/codex.test.cjs b/tests/codex.test.cjs index 9048595..b00fe67 100644 --- a/tests/codex.test.cjs +++ b/tests/codex.test.cjs @@ -160,6 +160,30 @@ test('Codex repository, session and project results show useful fields without J assert.deepEqual(JSON.parse(savedList.reason).data, [connection], 'the stored labels reach the mainsequence-mcp skill unchanged'); }); +test('the account names when the saved login ends: a warning within a day, and when it ended once it has', async () => { + const run = (action, data) => handleCodexPrompt({ prompt: `/ms ${action}` }, async () => ({ schemaVersion: 1, action, status: 'success', summary: `${action} completed.`, data })); + const local = at => { const d = new Date(at); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; }; + const session = { authenticated: true, username: 'fixture-user', backend: 'https://fixture.example', source: 'machine' }; + const soon = Date.now() + 3 * 3_600_000, later = Date.now() + 3 * 86_400_000, ended = Date.now() - 3_600_000; + const ending = await run('auth-status', { ...session, sessionEndsAt: new Date(soon).toISOString() }); + assert.match(ending.reason, new RegExp(`\\n\\nSigned in as fixture-user\\nMainSequence session ends (today|tomorrow) at ${local(soon)} — sign in again \\(/ms login\\)\\nBackend: https://fixture\\.example\\n`)); + const active = await run('auth-status', { ...session, sessionEndsAt: new Date(later).toISOString() }); + assert.match(active.reason, new RegExp(`Login shared with your SDKs\\nSession ends on \\d{1,2} [A-Z][a-z]{2} at ${local(later)}$`)); + assert.doesNotMatch(active.reason, /sign in again/i, 'no warning more than a day before the end'); + const over = await run('auth-status', { ...session, authenticated: false, sessionEndsAt: new Date(ended).toISOString() }); + assert.match(over.reason, new RegExp(`\\n\\nNot signed in\\nMainSequence session ended (today|yesterday) at ${local(ended)} — sign in again \\(/ms login\\)\\n`)); + assert.doesNotMatch((await run('auth-status', session)).reason, /session end/i, 'an environment login has no saved end'); + const status = await run('status', { project: { root: '/fixture project' }, session: { ...session, sessionEndsAt: new Date(soon).toISOString() } }); + assert.match(status.reason, /Signed in as fixture-user\nMainSequence session ends .+ — sign in again \(\/ms login\)/); +}); + +test('a refused permission (FORBIDDEN, HTTP 403) shows its message as an ordinary error with no sign-in prompt', async () => { + const fail = (code, message) => handleCodexPrompt({ prompt: '/ms repositories' }, async () => ({ schemaVersion: 1, action: 'repositories', status: 'failure', summary: message, error: { code, message } })); + const message = 'The backend https://fixture.example denied GET /api/v1/code-repositories/ (HTTP 403): the signed-in account does not have permission for it.'; + assert.equal((await fail('FORBIDDEN', message)).reason, `MainSequence · List code repositories · Failed\n\n${message}`); + for (const code of ['AUTH_REJECTED', 'NOT_AUTHENTICATED']) assert.match((await fail(code, 'Sign-in failed.')).reason, /\n\nSign in again: \/ms login$/); +}); + test('a literal --json option value does not request JSON output', async () => { const output = await handleCodexPrompt({ prompt: '/ms resolve --path "--json"' }, async request => ({ schemaVersion: 1, action: request.action, status: 'success', summary: `${request.action} completed.`, data: { root: request.root } })); assert.match(output.reason, /MainSequence · Find the backend repository\n\nCompleted successfully\./); diff --git a/tests/panel-discovery.test.cjs b/tests/panel-discovery.test.cjs index cfe5a16..acd8646 100644 --- a/tests/panel-discovery.test.cjs +++ b/tests/panel-discovery.test.cjs @@ -45,7 +45,7 @@ async function fixture(t, overrides = {}) { return new Response(JSON.stringify(result), { headers: { 'Content-Type': 'application/json' } }); }; const persistence = { name: 'fixture store', mode: 'machine', - read: async () => authenticated ? { username: overrides.savedUsername ?? username, access: 'private-machine-access', refresh: 'private-machine-refresh' } : null, + read: async () => authenticated ? { username: overrides.savedUsername ?? username, access: 'private-machine-access', refresh: overrides.refresh ?? 'private-machine-refresh' } : null, write: async () => writes.push('credential-write'), remove: async () => writes.push('credential-remove') }; const session = createSessionService(createNodeHost(), { persistence, fetch, env: {} }); const devtools = createDevTools({ host: createNodeHost(), session, env: { MAINSEQUENCE_ENDPOINT: backend }, fetch, mcpConnectionsPath: connections }); @@ -119,6 +119,36 @@ test('signed-out sessions do not perform discovery or sign in', async t => { assert.deepEqual(f.calls, []); assert.deepEqual(f.actions.map(a => a.action), ['auth-status']); }); +// A refresh token whose expiry devtools reads as the end of the saved login. +const refreshEnding = at => ['header', Buffer.from(JSON.stringify({ exp: Math.floor(at / 1000) })).toString('base64url'), 'signature'].join('.'); + +test('the verified account carries when the saved login ends', async t => { + const endsAt = Date.now() + 3 * 3_600_000, f = await fixture(t, { refresh: refreshEnding(endsAt) }); + const status = await createDiscovery().status(f.dispatch); + assert.equal(status.account.checked, true); assert.ok(status.contextId); + assert.equal(status.account.sessionEndsAt, new Date(Math.floor(endsAt / 1000) * 1000).toISOString()); +}); + +test('an ended saved login is signed out with its end, without contacting the backend', async t => { + const endedAt = Date.now() - 3_600_000, f = await fixture(t, { refresh: refreshEnding(endedAt) }); + const output = await createDiscovery().status(f.dispatch); + assert.equal(output.account.authenticated, false); assert.equal(output.contextId, null); assert.equal(output.issue, null); + assert.equal(output.account.sessionEndsAt, new Date(Math.floor(endedAt / 1000) * 1000).toISOString()); + assert.deepEqual(f.calls, []); assert.deepEqual(f.actions.map(a => a.action), ['auth-status']); +}); + +test('a refused permission (FORBIDDEN) is an ordinary error that keeps the context', async t => { + const f = await fixture(t), service = createDiscovery(), state = await service.status(f.dispatch), selected = { contextId: state.contextId, environmentUid }; + await service.view(f.dispatch, { ...selected, view: 'connections' }); + const message = `The backend ${backend} denied GET /api/v1/agents/ (HTTP 403): the signed-in account does not have permission for it.`; + const refused = { status: 'failure', summary: message, error: { code: 'FORBIDDEN', message } }; + const dispatch = async request => ['agents-list', 'mcp-call'].includes(request.action) ? { schemaVersion: 1, action: request.action, ...refused } : f.dispatch(request); + await assert.rejects(service.discover(dispatch, { ...selected, catalog: 'agents' }), { code: 'FORBIDDEN', message }); + const called = await service.invoke(dispatch, { ...selected, releaseUid, tool: 'metrics' }); + assert.deepEqual(called.error, { code: 'FORBIDDEN', message }, 'a refused tool call is returned, not turned into a context change'); + assert.equal((await service.view(f.dispatch, { ...selected, view: 'connections' })).count, 1, 'the context stays usable'); +}); + test('scope and pagination validation; unknown releases cannot acquire credentials', async t => { const f = await fixture(t), service = createDiscovery(), status = await service.status(f.dispatch); const base = { contextId: status.contextId, environmentUid }; const before = f.calls.length; diff --git a/tests/panel-ui.test.cjs b/tests/panel-ui.test.cjs index ef367c1..56028b5 100644 --- a/tests/panel-ui.test.cjs +++ b/tests/panel-ui.test.cjs @@ -6,7 +6,7 @@ const html = fs.readFileSync('src/hosts/panel/panel.html', 'utf8').replace('/* M function element() { return { value: '', disabled: false, textContent: '', hidden: false, children: [], attributes: {}, style: {setProperty(){}}, setAttribute(key, value) { this.attributes[key] = value; }, append(...nodes) { for (const n of nodes) n.parentNode = this; this.children.push(...nodes); }, replaceChildren(...nodes) { for (const n of nodes) n.parentNode = this; this.children = nodes; }, replaceWith(next) { const p = this.parentNode, i = p ? p.children.indexOf(this) : -1; if (i >= 0) { p.children[i] = next; next.parentNode = p; } }, focus() {} }; } -async function panel(handler, storage = new Map()) { +async function panel(handler, storage = new Map(), globals = {}) { // Minute-long timers (the icon re-check) are captured for the test to fire; shorter ones run for real. const scheduled = []; const schedule = (fn, ms) => { if (ms >= 60000) { const t = { fn, ms, cleared: false }; scheduled.push(t); return t; } return setTimeout(fn, ms); }; @@ -26,7 +26,7 @@ async function panel(handler, storage = new Map()) { } }; const document = { getElementById: id => els.get(id), createElement: () => element(), documentElement: { style: { setProperty: (k, v) => styles.set(k, v) } } }; const localStorage = {getItem:key=>storage.get(key)||null,setItem:(key,value)=>storage.set(key,value)}; - await vm.runInNewContext('(async()=>{' + html.match(/