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(/