Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ machine session; it does not replace a token exported into another shell. `logou
removes this backend's local record and separately reports whether remote revocation
completed.

A sign-in lasts as long as its refresh token: the backend ends it a fixed time after
sign-in (five days by default), however often it is renewed. `auth-status` reports that
moment as `sessionEndsAt` and names it in its summary. Once it has passed, the session
reads as signed out without contacting the backend, and every request answers "The saved
session for … ended on … UTC. Sign in again." A 401 means the backend no longer accepts
the login (expired, revoked, or another backend's): devtools renews once and otherwise asks
for a new sign-in. A 403 is a permission the account lacks: it is reported as `FORBIDDEN`,
with no renewal, no retry and no sign-in prompt.

A deliberately supplied `MAINSEQUENCE_ACCESS_TOKEN` with its matching
`MAINSEQUENCE_ENDPOINT` is a process or CI identity. A rejected process identity never
falls back to another saved identity. Runtime credentials are a separate application lane
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@dev-mainsequence/devtools",
"version": "0.3.14",
"version": "0.3.15",
"description": "Shared MainSequence developer actions, project operations, MCP client and command runner",
"license": "MIT",
"engines": {
Expand Down
10 changes: 8 additions & 2 deletions src/shared/actions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { McpConnections, McpRequest, createMcpConnectionCache, type McpConnectionCache } from './mcp';
import { AgentClient, AgentRequest, isAgentAction } from './agents';
import { ActionRequest, ActionResult, HostServices, ProjectAction, SessionService, ToolingError } from './models';
import { ActionRequest, ActionResult, HostServices, ProjectAction, SessionService, SessionStatus, ToolingError } from './models';
import { MachineSessionService } from './session';
import { listRepositories, projectContext, resolveBackend, resolveRepository } from './repositories';
import { DRY_RUN_ACTIONS, projectCapabilities, runProjectAction, setupRepository, withProjectLock } from './projects';
Expand All @@ -12,6 +12,12 @@ import { currentDirectory } from './workingDirectory';
import { createProjectToolCatalog, projectToolResult, type ProjectToolCatalogView, type ProjectToolScope } from './projectTools';

const UUID = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i;
/** Names when the saved login ends, or ended, so a person reading the summary knows to sign in. */
function authSummary(status: SessionStatus): string {
const at = status.sessionEndsAt ? status.sessionEndsAt.slice(0, 16).replace('T', ' ') + ' UTC' : '';
if (status.authenticated) return at ? `The selected session is usable until ${at}.` : 'The selected session is usable.';
return at && Date.parse(status.sessionEndsAt!) <= Date.now() ? `The saved session ended on ${at}. Sign in again.` : 'No usable session is selected.';
}
export function createDevTools(options: ConstructorParameters<typeof MachineSessionService>[1] & {
host: HostServices; session?: SessionService & { sanitize?(text: string): string }; allowMcpSkills?: boolean; allowLegacyProjectCredentials?: boolean;
mcpConnectionsPath?: string; mcpCache?:McpConnectionCache;
Expand Down Expand Up @@ -86,7 +92,7 @@ export function createDevTools(options: ConstructorParameters<typeof MachineSess
? await operation() : await withProjectLock(context.gitCommonDir, operation);
}
}
const summary = action === 'auth-status' ? ((data as any).authenticated ? 'The selected session is usable.' : 'No usable session is selected.')
const summary = action === 'auth-status' ? authSummary(data as SessionStatus)
: action === 'login' ? 'Signed in; the machine session was saved and verified.'
: action === 'refresh-token' ? 'The saved machine session was renewed.'
: action === 'logout' ? ((data as any).revoked ? 'The machine session was revoked and removed.' : 'The local machine session was removed; remote revocation was not confirmed.')
Expand Down
2 changes: 1 addition & 1 deletion src/shared/mcpTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export async function readMcpResponse(response: Response, message: Record<string
}
if (!response.ok) {
await response.body?.cancel();
throw new ToolingError(response.status === 401 || response.status === 403 ? 'AUTH_REJECTED' : 'API_ERROR',
throw new ToolingError(response.status === 401 ? 'AUTH_REJECTED' : response.status === 403 ? 'FORBIDDEN' : 'API_ERROR',
'The ' + target + ' returned HTTP ' + response.status + ' for ' + describeMcpMessage(message) + '.');
}
if (!Object.hasOwn(message, 'id')) {
Expand Down
4 changes: 3 additions & 1 deletion src/shared/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export type ActionRequest = McpRequest | AgentRequest | OrganizationProjectReque
})
| (BackendSelection & { action: 'setup'; repositoryUid: string; branch: string; baseDir: string; install?: boolean });

export type ErrorCode = 'CURRENT_DIRECTORY_UNAVAILABLE' | 'INVALID_REQUEST' | 'NOT_AUTHENTICATED' | 'AUTH_REJECTED' | 'STORE_UNAVAILABLE' | 'STORE_ERROR' | 'PERSISTENCE_FAILED' | 'BACKEND_UNREACHABLE' | 'API_ERROR' | 'MCP_TOOL_ERROR' | 'BROWSER_FAILED' | 'LOGIN_TIMEOUT' | 'INVALID_CALLBACK' | 'PROJECT_NOT_FOUND' | 'INVALID_GIT_CONTEXT' | 'PROJECT_TYPE_REQUIRED' | 'BACKEND_MISMATCH' | 'MISSING_PREREQUISITE' | 'UNSUPPORTED_ACTION' | 'PROJECT_BUSY' | 'PROCESS_FAILED' | 'PROCESS_TIMEOUT' | 'CANCELLED' | 'INTERNAL_ERROR';
export type ErrorCode = 'CURRENT_DIRECTORY_UNAVAILABLE' | 'INVALID_REQUEST' | 'NOT_AUTHENTICATED' | 'AUTH_REJECTED' | 'FORBIDDEN' | 'STORE_UNAVAILABLE' | 'STORE_ERROR' | 'PERSISTENCE_FAILED' | 'BACKEND_UNREACHABLE' | 'API_ERROR' | 'MCP_TOOL_ERROR' | 'BROWSER_FAILED' | 'LOGIN_TIMEOUT' | 'INVALID_CALLBACK' | 'PROJECT_NOT_FOUND' | 'INVALID_GIT_CONTEXT' | 'PROJECT_TYPE_REQUIRED' | 'BACKEND_MISMATCH' | 'MISSING_PREREQUISITE' | 'UNSUPPORTED_ACTION' | 'PROJECT_BUSY' | 'PROCESS_FAILED' | 'PROCESS_TIMEOUT' | 'CANCELLED' | 'INTERNAL_ERROR';
export class ToolingError extends Error {
constructor(public readonly code: ErrorCode, message: string) { super(message); this.name = 'ToolingError'; }
}
Expand All @@ -35,6 +35,8 @@ export type SessionStatus = {
store: string | null;
username?: string;
expiresAt?: string;
/** When the saved login ends: its refresh token's expiry. From then on the session is signed out. */
sessionEndsAt?: string;
checked?: boolean;
};
export type ActionCapability = { action: ProjectAction; state: 'available' | 'unavailable' | 'unsupported'; reason?: string };
Expand Down
47 changes: 34 additions & 13 deletions src/shared/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ function expiredOn(token: string): string {
const at = expiry(token);
return at && Date.parse(at) <= Date.now() ? ` on ${at.slice(0, 16).replace('T', ' ')} UTC` : '';
}
/** A saved login lasts as long as its refresh token; the backend refuses it after that `exp`. */
function ended(session: MachineSession | null | undefined): boolean {
const at = session?.refresh ? expiry(session.refresh) : undefined;
return !!at && Date.parse(at) <= Date.now();
}
/** Said instead of contacting the backend once the saved login has ended. */
function sessionEnded(backend: string, session: MachineSession, code: 'AUTH_REJECTED' | 'NOT_AUTHENTICATED' = 'AUTH_REJECTED'): ToolingError {
return new ToolingError(code, `The saved session for ${backend} ended${expiredOn(session.refresh)}. Sign in again.`);
}
/** Say which credentials the backend refused and how to repair them; no token value is included. */
function refusedCredentials(source: 'machine' | 'environment', backend: string, token: string): ToolingError {
const when = expiredOn(token);
Expand Down Expand Up @@ -157,7 +166,10 @@ export class MachineSessionService implements SessionService {
headers: { 'Content-Type': 'application/json', ...(apiPath === '/mcp' ? { Accept: 'application/json, text/event-stream', 'MCP-Protocol-Version': '2025-11-25' } : {}), ...(access ? { Authorization: `Bearer ${access}` } : {}) },
body: body === undefined ? undefined : JSON.stringify(body)
});
if (response.status === 401 || response.status === 403) throw new ToolingError('AUTH_REJECTED', 'The backend rejected the selected credentials.');
if (response.status === 401) throw new ToolingError('AUTH_REJECTED', 'The backend rejected the selected credentials.');
// 401 is the backend's answer for an expired, revoked or unknown login; 403 is a permission
// the account lacks, which no renewal or sign-in changes.
if (response.status === 403) throw new ToolingError('FORBIDDEN', `The backend ${backend} denied ${method} ${apiPath} (HTTP 403): the signed-in account does not have permission for it.`);
if (!response.ok) throw new ToolingError('API_ERROR', `The backend ${backend} returned HTTP ${response.status} for ${method} ${apiPath}.`);
if (apiPath === '/mcp') return await readMcpResponse(response, body as Record<string, unknown>, 'platform MCP ' + backend) as T;
if (response.status === 204) return undefined as T;
Expand All @@ -181,6 +193,7 @@ export class MachineSessionService implements SessionService {
const previous = await this.saved(backend);
if (!previous?.refresh) throw new ToolingError('NOT_AUTHENTICATED', 'No refreshable machine session is saved for this backend.');
if (expectedAccount !== undefined && previous.username && previous.username !== expectedAccount) throw this.accountChanged();
if (ended(previous)) throw sessionEnded(backend, previous);
// Another host process may have renewed while this one waited for the
// shared lease. Reuse its saved pair; never rotate it a second time.
if (stale && previous.access && previous.access !== stale.access) {
Expand Down Expand Up @@ -228,23 +241,28 @@ export class MachineSessionService implements SessionService {
let session = explicit || ((this.store || this.options.persistence) ? await this.saved(backend) : null);
const source = explicit ? 'environment' : session ? (this.options.persistence?.mode ?? 'machine') : 'none';
let expiresAt = expiry(session?.access || '');
let authenticated = Boolean(session?.access && (!expiresAt || Date.parse(expiresAt) > Date.now()) || (!explicit && session?.refresh));
// The end of the saved login is read from its refresh token, so an ended session is
// reported signed out without contacting the backend. A process identity has no renewal.
let sessionEndsAt = explicit ? undefined : expiry(session?.refresh || '');
const usableAccess = Boolean(session?.access && (!expiresAt || Date.parse(expiresAt) > Date.now()));
let authenticated = explicit ? usableAccess : Boolean((usableAccess || session?.refresh) && !ended(session));
if (check) {
if (!authenticated) throw new ToolingError('NOT_AUTHENTICATED', 'No usable session is selected for this backend.');
if (authenticated) {
const profile = await this.request<any>(backend, 'GET', '/api/v1/users/me/');
const username = profile?.user?.username || profile?.username;
if (typeof username === 'string') session = { ...session!, username };
authenticated = true;
expiresAt = expiry((explicit || await this.saved(backend))?.access || '');
}
if (!authenticated) throw !explicit && session && ended(session) ? sessionEnded(backend, session, 'NOT_AUTHENTICATED')
: new ToolingError('NOT_AUTHENTICATED', 'No usable session is selected for this backend.');
const profile = await this.request<any>(backend, 'GET', '/api/v1/users/me/');
const username = profile?.user?.username || profile?.username;
if (typeof username === 'string') session = { ...session!, username };
authenticated = true;
const current = explicit || await this.saved(backend);
expiresAt = expiry(current?.access || '');
sessionEndsAt = explicit ? undefined : expiry(current?.refresh || '');
}
return {
backend, authenticated, source,
persistence: explicit ? 'environment' : this.options.persistence?.mode ?? (this.store ? 'machine' : 'unavailable'),
store: this.options.persistence?.name ?? this.store?.name ?? null,
...(session?.username ? { username: session.username } : {}),
...(expiresAt ? { expiresAt } : {}), ...(check ? { checked: true } : {})
...(expiresAt ? { expiresAt } : {}), ...(sessionEndsAt ? { sessionEndsAt } : {}), ...(check ? { checked: true } : {})
};
}
async request<T>(input: string, method: 'GET' | 'POST', apiPath: string, body?: unknown, retrySafe = method === 'GET', options?: { timeoutMs?: number }): Promise<T> {
Expand Down Expand Up @@ -289,7 +307,8 @@ export class MachineSessionService implements SessionService {
const source = current.source === 'environment' ? 'environment' : this.options.persistence?.mode ?? 'machine';
return { backend, authenticated:true, source, persistence:source,
store:source === 'environment' ? null : this.options.persistence?.name ?? this.store?.name ?? null,
username:expectedAccount, expiresAt:expiry(current.session.access), checked:true };
username:expectedAccount, expiresAt:expiry(current.session.access),
...(source !== 'environment' && expiry(current.session.refresh) ? {sessionEndsAt:expiry(current.session.refresh)} : {}), checked:true };
},
login: unsupported, logout: unsupported, refresh: unsupported,
sanitize: text => this.sanitize(text),
Expand All @@ -301,6 +320,7 @@ export class MachineSessionService implements SessionService {
if (options?.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > 300000)) throw new ToolingError('INVALID_REQUEST', 'Request timeout must be between 1 and 300000 milliseconds.');
const backend = requireBackend(input);
let { session, source } = await this.selected(backend);
if (source === 'machine' && ended(session)) throw sessionEnded(backend, session);
if (!session.access || (expiry(session.access) && Date.parse(expiry(session.access)!) <= Date.now() + 30_000)) {
if (source === 'machine') session = await this.renew(backend, expectedAccount, session);
else if (!session.access) throw new ToolingError('NOT_AUTHENTICATED', 'The explicit environment has no access token.');
Expand Down Expand Up @@ -342,7 +362,8 @@ export class MachineSessionService implements SessionService {
}
private async machineStatus(backend: string): Promise<SessionStatus> {
const session = await this.saved(backend);
return { backend, authenticated: Boolean(session?.access || session?.refresh), source: session ? (this.options.persistence?.mode ?? 'machine') : 'none', persistence: this.options.persistence?.mode ?? 'machine', store: this.options.persistence?.name ?? this.store?.name ?? null, username: session?.username, expiresAt: expiry(session?.access || '') };
const sessionEndsAt = expiry(session?.refresh || '');
return { backend, authenticated: Boolean((session?.access || session?.refresh) && !ended(session)), source: session ? (this.options.persistence?.mode ?? 'machine') : 'none', persistence: this.options.persistence?.mode ?? 'machine', store: this.options.persistence?.name ?? this.store?.name ?? null, username: session?.username, expiresAt: expiry(session?.access || ''), ...(sessionEndsAt ? { sessionEndsAt } : {}) };
}
async logout(input: string): Promise<{ revoked: boolean }> {
const backend = requireBackend(input);
Expand Down
4 changes: 2 additions & 2 deletions tests/mcp.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -413,10 +413,10 @@ test('nameless release details still validate identity and all required MCP meta
assert.equal(await fs.readFile(f.file, 'utf8'), saved);
});

test('revocation, removed capabilities, changed URLs, and a wrong credential target stop before project execution', async t => {
test('withdrawn access, removed capabilities, changed URLs, and a wrong credential target stop before project execution', async t => {
const f = await fixture(t); await f.dispatch('mcp-add');
f.state.denied = true;
assert.equal((await f.dispatch('mcp-tools')).error.code, 'AUTH_REJECTED');
assert.equal((await f.dispatch('mcp-tools')).error.code, 'FORBIDDEN');
f.state.denied = false; f.state.noMcp = true;
assert.equal((await f.dispatch('mcp-tools')).error.code, 'API_ERROR');
f.state.noMcp = false; f.state.endpoint = 'https://changed.example/mcp';
Expand Down
Loading
Loading