Skip to content
Merged
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,20 @@ Prefer to host it yourself? You can point a memory at your own git remote instea
`agentage vault add <name> --git <remote>` - 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
Expand Down
15 changes: 10 additions & 5 deletions src/commands/status/status.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 => {
Expand Down Expand Up @@ -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<void> => {
const report = await gatherStatus(readAuth(), siteFqdn());
export const runStatus = async (opts: { json?: boolean; token?: string } = {}): Promise<void> => {
const report = await gatherStatus(resolveAuth({ token: opts.token }), siteFqdn());
if (opts.json) {
console.log(JSON.stringify(report, null, 2));
return;
Expand All @@ -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 <token>',
'authenticate with a personal access token (aga_...); or set AGENTAGE_TOKEN'
)
.action(runStatus);
};
59 changes: 59 additions & 0 deletions src/lib/auth/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, string>;
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);
});
});
4 changes: 4 additions & 0 deletions src/lib/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ const cliHeaders = (): Record<string, string> => 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<void> => {
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 {
Expand Down
112 changes: 112 additions & 0 deletions src/lib/auth/credentials.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
67 changes: 67 additions & 0 deletions src/lib/auth/credentials.ts
Original file line number Diff line number Diff line change
@@ -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 <aga_...>` 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();
};
10 changes: 10 additions & 0 deletions src/lib/auth/provision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
12 changes: 12 additions & 0 deletions src/lib/auth/provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/lib/fs/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down
10 changes: 10 additions & 0 deletions src/lib/status/status-info.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading