diff --git a/README.md b/README.md index bb14763..1ce119e 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,20 @@ Prefer to host it yourself? You can point a memory at your own git remote instea `agentage vault add --git ` - see [`docs/reference.md`](docs/reference.md) for details. +## Token auth (CI / headless) + +For CI or non-interactive machines, skip the browser sign-in and authenticate with a +personal access token. Mint one in the dashboard under **Settings -> API tokens** +(scopes `memory:read` / `memory:write`), then set it in the environment: + +```bash +export AGENTAGE_TOKEN=aga_... +agentage status +``` + +The token is used as the bearer for memory (MCP) calls; `--token aga_...` works per command +too. Account-channel provisioning still needs an interactive `agentage setup` session. + ## Going deeper - [`docs/architecture.md`](docs/architecture.md) - how the CLI, the local helper, your diff --git a/src/commands/status/status.ts b/src/commands/status/status.ts index 1b149b6..034ec09 100644 --- a/src/commands/status/status.ts +++ b/src/commands/status/status.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import { type Command } from 'commander'; -import { readAuth } from '../../lib/fs/config.js'; +import { resolveAuth } from '../../lib/auth/credentials.js'; import { siteFqdn } from '../../lib/net/origins.js'; import { formatUptime } from '../../lib/status/format.js'; import { @@ -24,10 +24,11 @@ const authLine = (auth: StatusReport['auth']): string => { // Env mismatch: the credential is valid but for another target - neutral `!`, not the expired ✗. if (auth.mismatch) return `${chalk.yellow('!')} ${auth.note ?? 'signed in to another environment'}`; + const via = auth.pat ? ' via token' : ''; if (!auth.signedIn) return `${mark(false)} ${auth.note ?? 'not signed in'}`; // Transient: we hold a valid-looking token but could not re-verify - a non-terminal `~`, never ✗. - if (auth.transient) return `${chalk.yellow('~')} ${auth.note ?? 'signed in'}`; - return `${mark(true)} signed in (session active)`; + if (auth.transient) return `${chalk.yellow('~')} ${auth.note ?? 'signed in'}${via}`; + return `${mark(true)} signed in${via} (session active)`; }; const updateLine = (update: UpdateInfo): string => { @@ -81,8 +82,8 @@ export const printStatus = (report: StatusReport): void => { if (report.update.message) console.log(chalk.yellow(`\n${report.update.message}`)); }; -export const runStatus = async (opts: { json?: boolean } = {}): Promise => { - const report = await gatherStatus(readAuth(), siteFqdn()); +export const runStatus = async (opts: { json?: boolean; token?: string } = {}): Promise => { + const report = await gatherStatus(resolveAuth({ token: opts.token }), siteFqdn()); if (opts.json) { console.log(JSON.stringify(report, null, 2)); return; @@ -95,5 +96,9 @@ export const registerStatus = (program: Command): void => { .command('status') .description('Show CLI, account, and endpoint status') .option('--json', 'machine-readable output') + .option( + '--token ', + 'authenticate with a personal access token (aga_...); or set AGENTAGE_TOKEN' + ) .action(runStatus); }; diff --git a/src/lib/auth/api.test.ts b/src/lib/auth/api.test.ts index 9a45259..b0bfa50 100644 --- a/src/lib/auth/api.test.ts +++ b/src/lib/auth/api.test.ts @@ -8,8 +8,10 @@ import { AuthRequiredError, currentBearer, introspectToken, + refreshOrThrow, TransientAuthError, } from './api.js'; +import { patAuthState } from './credentials.js'; import { readAuth, type AuthState } from '../fs/config.js'; import { links } from '../net/origins.js'; import { VERSION } from '../../utils/version.js'; @@ -376,3 +378,60 @@ describe('introspectToken', () => { expect(tokenCalls).toHaveLength(1); }); }); + +// A PAT-backed credential must ride through the same authed request paths as an OAuth access token +// (it IS an oauthAccessToken row server-side) - but it carries no refresh token and must never +// attempt an OAuth refresh. +describe('PAT-backed AuthState', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'agentage-api-')); + process.env['AGENTAGE_CONFIG_DIR'] = dir; + }); + + afterEach(() => { + delete process.env['AGENTAGE_CONFIG_DIR']; + rmSync(dir, { recursive: true, force: true }); + vi.unstubAllGlobals(); + }); + + const pat = patAuthState('aga_test123', 'dev.agentage.io'); + + it('sends the PAT as the Bearer on an authed GET', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, { ok: true })); + vi.stubGlobal('fetch', fetchMock); + await authedGet(pat, target, 'https://memory.dev.agentage.io/mcp/me'); + expect(fetchMock).toHaveBeenCalledWith('https://memory.dev.agentage.io/mcp/me', { + headers: { authorization: 'Bearer aga_test123', ...versionHeaders }, + redirect: 'manual', + }); + }); + + it('sends the PAT as the Bearer on an authed POST', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, { ok: true })); + vi.stubGlobal('fetch', fetchMock); + await authedPost(pat, target, 'https://memory.dev.agentage.io/mcp', { jsonrpc: '2.0' }); + const headers = (fetchMock.mock.calls[0]?.[1] as RequestInit).headers as Record; + expect(headers['authorization']).toBe('Bearer aga_test123'); + }); + + it('refuses to refresh a PAT (no OAuth refresh grant) with a dashboard-pointing message', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + await expect(refreshOrThrow(pat, target)).rejects.toThrow(AuthRequiredError); + await expect(refreshOrThrow(pat, target)).rejects.toThrow(/personal access token/); + // Never hits the token endpoint - a PAT cannot be refreshed. + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not attempt a refresh on a 401 (a PAT is terminal on 401)', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(401, {})); + vi.stubGlobal('fetch', fetchMock); + await expect(authedGet(pat, target, 'https://memory.dev.agentage.io/mcp/me')).rejects.toThrow( + AuthRequiredError + ); + // Exactly one call (the original) - no /token refresh attempt. + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/auth/api.ts b/src/lib/auth/api.ts index 98c9ede..e1de92b 100644 --- a/src/lib/auth/api.ts +++ b/src/lib/auth/api.ts @@ -13,6 +13,10 @@ const cliHeaders = (): Record => requestHeaders({ component: 'cl // throws: AuthRequiredError when the grant is dead (401/invalid_grant/invalid_client), else // TransientAuthError (429/5xx/network/timeout) - a blip that must not be read as a dead session. export const refreshOrThrow = async (auth: AuthState, links: Links): Promise => { + if (auth.kind === 'pat') + throw new AuthRequiredError( + 'personal access token expired or revoked - mint a new one in the dashboard (Settings -> API tokens)' + ); if (!auth.tokens.refreshToken) throw new AuthRequiredError('no refresh token'); let fresh; try { diff --git a/src/lib/auth/credentials.test.ts b/src/lib/auth/credentials.test.ts new file mode 100644 index 0000000..10ef5eb --- /dev/null +++ b/src/lib/auth/credentials.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { type AuthState } from '../fs/config.js'; +import { + assertPatShape, + isPatAuth, + isPatShape, + patAuthState, + rawPatToken, + resolveAuth, + TOKEN_ENV_VAR, +} from './credentials.js'; + +const stored: AuthState = { + siteFqdn: 'dev.agentage.io', + clientId: 'c1', + tokens: { accessToken: 'oauth-access', refreshToken: 'rt' }, +}; + +const readStored = (): AuthState | null => stored; + +describe('isPatShape', () => { + it('accepts an aga_ token with a non-empty tail', () => { + expect(isPatShape('aga_abc123')).toBe(true); + }); + + it('rejects a bare prefix, a wrong prefix, and an empty string', () => { + expect(isPatShape('aga_')).toBe(false); + expect(isPatShape('sk_abc')).toBe(false); + expect(isPatShape('')).toBe(false); + }); +}); + +describe('assertPatShape', () => { + it('trims and returns a valid token', () => { + expect(assertPatShape(' aga_abc ')).toBe('aga_abc'); + }); + + it('throws a dashboard-pointing message on a malformed token', () => { + expect(() => assertPatShape('nope')).toThrow(/personal access token starting with "aga_"/); + }); +}); + +describe('rawPatToken (flag > env)', () => { + const original = process.env[TOKEN_ENV_VAR]; + beforeEach(() => delete process.env[TOKEN_ENV_VAR]); + afterEach(() => { + if (original === undefined) delete process.env[TOKEN_ENV_VAR]; + else process.env[TOKEN_ENV_VAR] = original; + }); + + it('prefers the flag over the env var', () => { + process.env[TOKEN_ENV_VAR] = 'aga_from_env'; + expect(rawPatToken({ token: 'aga_from_flag' })).toBe('aga_from_flag'); + }); + + it('falls back to the env var when no flag is given', () => { + process.env[TOKEN_ENV_VAR] = 'aga_from_env'; + expect(rawPatToken()).toBe('aga_from_env'); + }); + + it('treats an empty flag or empty/whitespace env value as unset', () => { + process.env[TOKEN_ENV_VAR] = ' '; + expect(rawPatToken({ token: ' ' })).toBeUndefined(); + delete process.env[TOKEN_ENV_VAR]; + expect(rawPatToken()).toBeUndefined(); + }); +}); + +describe('patAuthState', () => { + it('synthesizes a pat-marked, refresh-less credential pinned to the target fqdn', () => { + const auth = patAuthState('aga_tok', 'dev.agentage.io'); + expect(auth.kind).toBe('pat'); + expect(auth.tokens.accessToken).toBe('aga_tok'); + expect(auth.tokens.refreshToken).toBeUndefined(); + expect(auth.siteFqdn).toBe('dev.agentage.io'); + expect(isPatAuth(auth)).toBe(true); + expect(isPatAuth(stored)).toBe(false); + }); +}); + +describe('resolveAuth (precedence flag > env > stored OAuth)', () => { + const original = process.env[TOKEN_ENV_VAR]; + beforeEach(() => delete process.env[TOKEN_ENV_VAR]); + afterEach(() => { + if (original === undefined) delete process.env[TOKEN_ENV_VAR]; + else process.env[TOKEN_ENV_VAR] = original; + }); + + it('uses the flag PAT over the env PAT and the stored OAuth session', () => { + process.env[TOKEN_ENV_VAR] = 'aga_env'; + const auth = resolveAuth({ token: 'aga_flag' }, readStored); + expect(auth?.kind).toBe('pat'); + expect(auth?.tokens.accessToken).toBe('aga_flag'); + }); + + it('uses the env PAT over the stored OAuth session when no flag', () => { + process.env[TOKEN_ENV_VAR] = 'aga_env'; + const auth = resolveAuth({}, readStored); + expect(auth?.kind).toBe('pat'); + expect(auth?.tokens.accessToken).toBe('aga_env'); + }); + + it('falls back to the stored OAuth session when no PAT is present', () => { + const auth = resolveAuth({}, readStored); + expect(auth?.kind).toBeUndefined(); + expect(auth?.tokens.accessToken).toBe('oauth-access'); + }); + + it('returns null when no PAT and no stored session', () => { + expect(resolveAuth({}, () => null)).toBeNull(); + }); +}); diff --git a/src/lib/auth/credentials.ts b/src/lib/auth/credentials.ts new file mode 100644 index 0000000..0658514 --- /dev/null +++ b/src/lib/auth/credentials.ts @@ -0,0 +1,67 @@ +import { readAuth, type AuthState } from '../fs/config.js'; +import { siteFqdn } from '../net/origins.js'; + +// Personal access tokens (PATs) are opaque platform tokens minted in the dashboard (Settings -> +// API tokens). They are `oauthAccessToken` rows, so the cloud memory MCP + the OAuth introspection +// endpoint validate them exactly like an OAuth access token - anywhere the CLI sends a stored +// access token, a PAT works as the bearer. They carry no refresh token and are non-interactive: +// they are the CI / headless credential. + +export const PAT_PREFIX = 'aga_'; + +// Env var (CI-friendly, primary) and the per-command flag name for the PAT. +export const TOKEN_ENV_VAR = 'AGENTAGE_TOKEN'; + +export interface PatOptions { + // An explicit per-command `--token ` value; takes precedence over the env var. + token?: string; +} + +// A PAT is opaque; we only assert the `aga_` shape and non-empty tail so a copy-paste slip fails +// with a clear message instead of a raw 401 later. The server is the real validator. +export const isPatShape = (value: string): boolean => + value.startsWith(PAT_PREFIX) && value.length > PAT_PREFIX.length; + +export const assertPatShape = (value: string): string => { + const token = value.trim(); + if (!isPatShape(token)) + throw new Error( + `token must be a personal access token starting with "${PAT_PREFIX}" ` + + `(mint one in the dashboard: Settings -> API tokens)` + ); + return token; +}; + +// The raw PAT from flag > env, or undefined when neither is set. An empty/whitespace env value is +// treated as unset so a stray `export AGENTAGE_TOKEN=` never shadows a stored OAuth session. +export const rawPatToken = (opts: PatOptions = {}): string | undefined => { + if (opts.token !== undefined && opts.token.trim() !== '') return opts.token.trim(); + const env = process.env[TOKEN_ENV_VAR]; + if (env !== undefined && env.trim() !== '') return env.trim(); + return undefined; +}; + +// A synthesized AuthState backed by a PAT: the token rides through as the bearer everywhere an +// OAuth access token would. `kind: 'pat'` marks it so refresh / OAuth-session-only paths can fail +// with a clear message instead of attempting an impossible refresh. siteFqdn is pinned to the +// current target so the env-mismatch guard never fires (a PAT is not tied to a stored environment). +export const patAuthState = (token: string, fqdn: string = siteFqdn()): AuthState => ({ + siteFqdn: fqdn, + clientId: 'pat', + kind: 'pat', + tokens: { accessToken: assertPatShape(token) }, +}); + +export const isPatAuth = (auth: AuthState | null): boolean => auth?.kind === 'pat'; + +// Resolve the active credential with precedence flag > env PAT > stored OAuth. When a PAT is +// present the OAuth/DCR flow is skipped entirely and the PAT is the bearer; otherwise fall back to +// the stored OAuth session on disk (null when signed out). +export const resolveAuth = ( + opts: PatOptions = {}, + read: () => AuthState | null = readAuth +): AuthState | null => { + const pat = rawPatToken(opts); + if (pat !== undefined) return patAuthState(pat); + return read(); +}; diff --git a/src/lib/auth/provision.test.ts b/src/lib/auth/provision.test.ts index 64cce29..6025dc4 100644 --- a/src/lib/auth/provision.test.ts +++ b/src/lib/auth/provision.test.ts @@ -92,6 +92,16 @@ describe('provisionAccountVault', () => { expect(post).not.toHaveBeenCalled(); }); + it('PAT credential -> unauthenticated without any network call (MCP-only, no REST provision)', async () => { + const post = vi.fn(async () => jsonResponse(201)); + const patAuth: AuthState = { ...auth, kind: 'pat', tokens: { accessToken: 'aga_x' } }; + const { deps } = makeDeps({ readAuth: () => patAuth, post }); + const res = await provisionAccountVault('acct', deps); + expect(res.status).toBe('unauthenticated'); + expect(res.message).toContain('personal access token only authorizes memory'); + expect(post).not.toHaveBeenCalled(); + }); + it('network failure -> offline, kept locally, will provision when online', async () => { const post = vi.fn(async () => { throw new Error('ECONNREFUSED'); diff --git a/src/lib/auth/provision.ts b/src/lib/auth/provision.ts index da1f3dd..1847af7 100644 --- a/src/lib/auth/provision.ts +++ b/src/lib/auth/provision.ts @@ -51,6 +51,18 @@ export const provisionAccountVault = async ( message: registeredLocally(name, ' - run `agentage setup` to sync.'), }; } + // A PAT is an MCP-surface credential; the backend REST provisioning endpoint rejects plain + // bearers (only session cookies), so it cannot provision an account channel. Fail clearly. + if (auth.kind === 'pat') { + return { + status: 'unauthenticated', + message: registeredLocally( + name, + ' - account-channel provisioning needs an interactive session (run `agentage setup`); ' + + 'a personal access token only authorizes memory (MCP) calls.' + ), + }; + } const links = deps.links(); let res: Response; diff --git a/src/lib/fs/config.ts b/src/lib/fs/config.ts index a42b491..121ab02 100644 --- a/src/lib/fs/config.ts +++ b/src/lib/fs/config.ts @@ -23,6 +23,9 @@ export interface AuthState { clientId: string; tokens: StoredTokens; user?: { id: string; email: string }; + // 'pat' marks a credential synthesized from a personal access token (AGENTAGE_TOKEN / --token): + // it is never persisted, carries no refresh token, and must not attempt an OAuth refresh. + kind?: 'pat'; } export const getConfigDir = (): string => diff --git a/src/lib/status/status-info.test.ts b/src/lib/status/status-info.test.ts index ff36ea7..315f867 100644 --- a/src/lib/status/status-info.test.ts +++ b/src/lib/status/status-info.test.ts @@ -89,6 +89,16 @@ describe('gatherStatus', () => { expect(report.auth).toEqual({ signedIn: true, tokenExpiresAt: '2026-06-12T20:00:00Z' }); }); + it('flags pat auth when the credential is a personal access token', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => jsonResponse(200, { userId: 'u1', accessTokenExpiresAt: 'x' })) + ); + const patAuth: AuthState = { ...auth, kind: 'pat', tokens: { accessToken: 'aga_x' } }; + const report = await gatherStatus(patAuth, 'dev.agentage.io'); + expect(report.auth).toEqual({ signedIn: true, tokenExpiresAt: 'x', pat: true }); + }); + it('refreshes on a 200 + null session and reports signed-in', async () => { vi.stubGlobal( 'fetch', diff --git a/src/lib/status/status-info.ts b/src/lib/status/status-info.ts index 236a146..c3c2a30 100644 --- a/src/lib/status/status-info.ts +++ b/src/lib/status/status-info.ts @@ -39,6 +39,9 @@ export interface StatusReport { tokenExpiresAt?: string; note?: string; transient?: boolean; + // Set when authenticated via a personal access token (AGENTAGE_TOKEN / --token) rather than a + // stored OAuth session, so the printer can label the line. + pat?: boolean; // Set when the stored credential's env differs from the current target: signedIn is false and the // printer renders a neutral "signed in to X - CLI targets Y" line, never "session expired". mismatch?: AuthEnvMismatch; @@ -157,11 +160,13 @@ export const gatherStatus = async (auth: AuthState | null, fqdn: string): Promis report.auth = mismatchAuth(mismatch); return report; } + // Only carry `pat: true`; omit it for OAuth so existing exact-match reports stay unchanged. + const patFlag = auth.kind === 'pat' ? { pat: true } : {}; try { const session = await introspectToken(auth, target); - report.auth = { signedIn: true, tokenExpiresAt: session.expiresAt }; + report.auth = { signedIn: true, tokenExpiresAt: session.expiresAt, ...patFlag }; } catch (err) { - report.auth = classifyAuthError(err); + report.auth = { ...classifyAuthError(err), ...patFlag }; } return report; };