Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
46314fa
feat(cli): add account session store and auth core
marslavish Aug 3, 2026
93719c1
feat(pi): export deriveSubdomainEndpoint
marslavish Aug 3, 2026
8b404b6
feat(cli): add login, logout and whoami commands
marslavish Aug 3, 2026
a8f9d67
feat(pi): host-configurable signInHint in signed-out reasons
marslavish Aug 3, 2026
9f19ab1
feat(cli): db tools read login session, startup key remint
marslavish Aug 3, 2026
254802d
fix(cli): use workspace:^ for sdk dependency
marslavish Aug 3, 2026
ea21a20
fix(cli): disable inquirerer idle timeout in interactive login
marslavish Aug 3, 2026
4612160
feat(harness): default constructive-skills source
marslavish Aug 3, 2026
57791c2
feat(pi): manage_entity_types tool (list/create/delete)
marslavish Aug 3, 2026
9470663
feat(harness): confirm-gate manage_entity_types mutations
marslavish Aug 3, 2026
8acfcd8
feat(pi): create_api_key tool + secret-delivery host hooks
marslavish Aug 3, 2026
69e13af
feat(harness): confirm-gate create_api_key
marslavish Aug 3, 2026
6be011a
feat(pi): step-up request context + cwd in secret delivery
marslavish Aug 3, 2026
5f531ae
fix(pi): verify existing principal scope before unscoped reuse
marslavish Aug 3, 2026
dc0cab8
fix(pi): mint reused-principal keys with the identity userId
marslavish Aug 3, 2026
922fd84
fix(cli): split coalesced stdin chunks so pasted passwords survive login
marslavish Aug 3, 2026
385fcc8
test(cli): expect 18 db tools after pi tool additions
marslavish Aug 3, 2026
d9dd93d
Merge main into agent-login (inquirerer 4.9.3)
pyramation Aug 4, 2026
412ba27
feat(codegen): cli.stashName so generated CLIs can share one signed-i…
pyramation Aug 4, 2026
20cb70d
feat(cli): one shared Constructive login store
pyramation Aug 4, 2026
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
17 changes: 15 additions & 2 deletions agentic/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ The merged tree is materialized into the appstash-owned agent dir, so the sessio
| `agent [options...]` | Start an interactive session with the harness skills |
| `agent -p "prompt"` | One-shot print mode |
| `agent init` | Configure the skills source (repo + pin) interactively |
| `agent login` | Sign in to the Constructive platform (interactive) |
| `agent logout` | Revoke the API key and clear the stored session |
| `agent whoami` | Show the signed-in account, backend, and masked API key |
| `agent skills list` | Resolve and list the effective skill set per layer |
| `agent skills update` | Re-fetch the base release and re-materialize |
| `agent help` | Usage |
Expand All @@ -60,12 +63,22 @@ The merged tree is materialized into the appstash-owned agent dir, so the sessio

Env overrides: `AGENT_SKILLS_REPO`, `AGENT_SKILLS_PIN`, `AGENT_HOME` (appstash base dir), `GITHUB_TOKEN` (private skills repos).

Db-tool credentials (read per tool call; without them the tools respond "not signed in"):
## Authentication

`agent login` signs in to the Constructive platform with email and password. The flow asks for a backend first: `localnet` (a local backend on `*.localhost:3000`), `devnet` (`*.launchql.dev`), or a custom API URL. After sign-in, the CLI mints a 5-year API key for the db tools and checks it on each startup. `agent logout` revokes the key and deletes the session.

Credential files (mode `0600`, plaintext):

- `~/.constructive/config/agent/account.json` — the signed-in session (user, access token, API key)
- `~/.constructive/config/agent/backend-config.json` — the selected backend endpoints

The db tools read these files on each tool call, so a login mid-session is picked up without a restart. Tokens never enter the environment of child processes.

Env overrides for CI and headless use (these win over the stored session; read per tool call):
`CONSTRUCTIVE_USER_ID`, `CONSTRUCTIVE_ACCESS_TOKEN`, `CONSTRUCTIVE_API_KEY` (optional), `CONSTRUCTIVE_API_ENDPOINT`, `CONSTRUCTIVE_MODULES_ENDPOINT` (default to the local backend).

## Roadmap

- `agent login` — sign in to a Constructive backend and persist credentials in appstash instead of env vars.
- Harness system-prompt section + templates/prompts materialization, shared with constructive-desktop via a common adapter package.

## Credits
Expand Down
135 changes: 135 additions & 0 deletions agentic/cli/__tests__/api-key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import {
API_KEY_NAME,
buildCreateApiKeyInput,
classifyApiKeyError,
MintedApiKey,
needsRemint,
parseMintedKey,
REMINT_THRESHOLD_MS,
remintApiKey
} from '../src/api-key';

describe('buildCreateApiKeyInput', () => {
it('names the key agentic-cli with full access and no MFA', () => {
expect(buildCreateApiKeyInput({ years: 5 })).toEqual({
keyName: API_KEY_NAME,
accessLevel: 'full_access',
mfaLevel: 'none',
expiresIn: { years: 5 }
});
expect(API_KEY_NAME).toBe('agentic-cli');
});
});

describe('parseMintedKey', () => {
it('returns the minted key and maps expiresAt', () => {
expect(parseMintedKey({ apiKey: 'k', keyId: 'id', expiresAt: '2031-01-01' })).toEqual({
apiKey: 'k',
keyId: 'id',
apiKeyExpiresAt: '2031-01-01'
});
expect(parseMintedKey({ apiKey: 'k', keyId: 'id' })).toEqual({ apiKey: 'k', keyId: 'id' });
});

it('throws when the backend returns no secret', () => {
expect(() => parseMintedKey(undefined)).toThrow('createApiKey returned no API key.');
expect(() => parseMintedKey({ keyId: 'id' })).toThrow('createApiKey returned no API key.');
});
});

describe('classifyApiKeyError', () => {
it('maps extension codes', () => {
const err = { errors: [{ extensions: { code: 'STEP_UP_REQUIRED' } }] };
expect(classifyApiKeyError(err)).toBe('step-up-required');
});

it('falls back to message text and then unknown', () => {
expect(classifyApiKeyError(new Error('boom: API_KEY_LIMIT_REACHED'))).toBe('limit-reached');
expect(classifyApiKeyError(new Error('NOT_AUTHENTICATED'))).toBe('not-authenticated');
expect(classifyApiKeyError(new Error('nope'))).toBe('unknown');
expect(classifyApiKeyError(undefined)).toBe('unknown');
});
});

describe('needsRemint', () => {
const now = Date.parse('2026-08-03T00:00:00Z');

it('is false without an expiry or with an unparsable one', () => {
expect(needsRemint({ apiKeyExpiresAt: undefined, now })).toBe(false);
expect(needsRemint({ apiKeyExpiresAt: 'garbage', now })).toBe(false);
});

it('is true at or inside the 7-day threshold, false outside', () => {
const at = (deltaMs: number) => new Date(now + deltaMs).toISOString();
expect(needsRemint({ apiKeyExpiresAt: at(REMINT_THRESHOLD_MS), now })).toBe(true);
expect(needsRemint({ apiKeyExpiresAt: at(REMINT_THRESHOLD_MS + 1000), now })).toBe(false);
expect(needsRemint({ apiKeyExpiresAt: at(-1000), now })).toBe(true);
});
});

describe('remintApiKey', () => {
const minted: MintedApiKey = { apiKey: 'new', keyId: 'new-id' };

it('mints first, then revokes the old key', async () => {
const calls: string[] = [];
const result = await remintApiKey({
currentKeyId: 'old-id',
revoke: async keyId => {
calls.push(`revoke:${keyId}`);
},
mint: async () => {
calls.push('mint');
return minted;
}
});
expect(result).toEqual(minted);
expect(calls).toEqual(['mint', 'revoke:old-id']);
});

it('on limit-reached, revokes first and retries the mint once', async () => {
const calls: string[] = [];
let attempts = 0;
const result = await remintApiKey({
currentKeyId: 'old-id',
revoke: async keyId => {
calls.push(`revoke:${keyId}`);
},
mint: async () => {
calls.push('mint');
attempts += 1;
if (attempts === 1) throw { errors: [{ extensions: { code: 'API_KEY_LIMIT_REACHED' } }] };
return minted;
}
});
expect(result).toEqual(minted);
expect(calls).toEqual(['mint', 'revoke:old-id', 'mint']);
});

it('rethrows non-limit errors without revoking', async () => {
const revoke = jest.fn();
await expect(
remintApiKey({
currentKeyId: 'old-id',
revoke,
mint: async () => {
throw { errors: [{ extensions: { code: 'STEP_UP_REQUIRED' } }] };
}
})
).rejects.toBeDefined();
expect(revoke).not.toHaveBeenCalled();
});

it('tolerates a failing revoke', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
const result = await remintApiKey({
currentKeyId: 'old-id',
revoke: async () => {
throw new Error('offline');
},
mint: async () => minted
});
expect(result).toEqual(minted);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});
225 changes: 225 additions & 0 deletions agentic/cli/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import { ConfigStore } from 'appstash';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

const createClient = jest.fn();

jest.mock('@constructive-io/sdk', () => ({
auth: { createClient: (...args: any[]) => createClient(...args) }
}));

import { loadSession, saveSession } from '../src/account-store';
import { refreshApiKeyIfNeeded, signIn, signOut } from '../src/auth';
import { loadConfig } from '../src/config';

const AUTH_ENDPOINT = 'http://auth.localhost:3000/graphql';

const unwrappable = (value: unknown) => ({ unwrap: () => Promise.resolve(value) });
const failing = (err: unknown) => ({ unwrap: () => Promise.reject(err) });

let home: string;
let store: ConfigStore;

beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-cli-auth-'));
store = loadConfig(home).store;
createClient.mockReset();
});

afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
});

function mockClient(mutation: Record<string, jest.Mock>) {
createClient.mockImplementation(() => ({ mutation }));
return mutation;
}

const signInResult = {
signIn: {
result: {
userId: 'user-1',
accessToken: 'access-token',
accessTokenExpiresAt: '2026-08-04T00:00:00Z'
}
}
};

const mintedResult = {
createApiKey: {
result: { apiKey: 'cnc_live_sk_new', keyId: 'key-new', expiresAt: '2031-08-03T00:00:00Z' }
}
};

describe('signIn', () => {
it('persists the session and mints an API key in the step-up window', async () => {
const mutation = mockClient({
signIn: jest.fn(() => unwrappable(signInResult)),
createApiKey: jest.fn(() => unwrappable(mintedResult)),
revokeApiKey: jest.fn()
});

const session = await signIn({
store,
authEndpoint: AUTH_ENDPOINT,
email: ' dev@example.com ',
password: 'pw'
});

expect(mutation.signIn).toHaveBeenCalledWith(
{ input: { email: 'dev@example.com', password: 'pw' } },
expect.anything()
);
expect(session.userId).toBe('user-1');
expect(session.apiKey).toBe('cnc_live_sk_new');
expect(session.keyId).toBe('key-new');
expect(loadSession(store)).toEqual(session);
expect(createClient).toHaveBeenCalledWith({ endpoint: AUTH_ENDPOINT });
expect(createClient).toHaveBeenCalledWith({
endpoint: AUTH_ENDPOINT,
headers: { Authorization: 'Bearer access-token' }
});
});

it('keeps the session when the mint fails', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
mockClient({
signIn: jest.fn(() => unwrappable(signInResult)),
createApiKey: jest.fn(() => failing(new Error('API_KEYS_DISABLED'))),
revokeApiKey: jest.fn()
});

const session = await signIn({ store, authEndpoint: AUTH_ENDPOINT, email: 'dev@example.com', password: 'pw' });
expect(session.apiKey).toBeUndefined();
expect(loadSession(store)?.accessToken).toBe('access-token');
warn.mockRestore();
});

it('rejects when no access token comes back (MFA)', async () => {
mockClient({ signIn: jest.fn(() => unwrappable({ signIn: { result: {} } })) });
await expect(
signIn({ store, authEndpoint: AUTH_ENDPOINT, email: 'dev@example.com', password: 'pw' })
).rejects.toThrow('Authentication returned no access token (MFA may be required).');
expect(loadSession(store)).toBeNull();
});

it('rejects empty credentials without a network call', async () => {
await expect(
signIn({ store, authEndpoint: AUTH_ENDPOINT, email: ' ', password: 'pw' })
).rejects.toThrow('Email and password are required.');
expect(createClient).not.toHaveBeenCalled();
});

it('maps sign-in errors through describeAuthError', async () => {
const gqlError = Object.assign(new Error('request failed'), {
errors: [{ message: 'GraphQL Error: Invalid credentials' }]
});
mockClient({ signIn: jest.fn(() => failing(gqlError)) });
await expect(
signIn({ store, authEndpoint: AUTH_ENDPOINT, email: 'dev@example.com', password: 'nope' })
).rejects.toThrow('Invalid credentials');
});
});

describe('refreshApiKeyIfNeeded', () => {
const baseSession = {
userId: 'user-1',
email: 'dev@example.com',
accessToken: 'access-token',
signedInAt: 1754000000000
};

it('returns signed-out without a session', async () => {
await expect(refreshApiKeyIfNeeded({ store, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('signed-out');
});

it('returns ok for a fresh key without a network call', async () => {
saveSession(store, {
...baseSession,
apiKey: 'k',
keyId: 'id',
apiKeyExpiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365).toISOString()
});
await expect(refreshApiKeyIfNeeded({ store, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('ok');
expect(createClient).not.toHaveBeenCalled();
});

it('re-mints an expiring key and persists it', async () => {
saveSession(store, {
...baseSession,
apiKey: 'old',
keyId: 'old-id',
apiKeyExpiresAt: new Date(Date.now() + 1000).toISOString()
});
const mutation = mockClient({
createApiKey: jest.fn(() => unwrappable(mintedResult)),
revokeApiKey: jest.fn(() => unwrappable({ revokeApiKey: { result: true } }))
});

await expect(refreshApiKeyIfNeeded({ store, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('reminted');
expect(mutation.revokeApiKey).toHaveBeenCalledWith({ input: { keyId: 'old-id' } }, expect.anything());
expect(loadSession(store)?.apiKey).toBe('cnc_live_sk_new');
});

it('returns reauth-required on a step-up error', async () => {
saveSession(store, baseSession);
mockClient({
createApiKey: jest.fn(() => failing({ errors: [{ extensions: { code: 'STEP_UP_REQUIRED' } }] })),
revokeApiKey: jest.fn()
});
await expect(refreshApiKeyIfNeeded({ store, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('reauth-required');
});

it('returns unavailable on other errors', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
saveSession(store, baseSession);
mockClient({
createApiKey: jest.fn(() => failing(new Error('boom'))),
revokeApiKey: jest.fn()
});
await expect(refreshApiKeyIfNeeded({ store, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('unavailable');
warn.mockRestore();
});
});

describe('signOut', () => {
it('revokes the key and clears the session', async () => {
saveSession(store, {
userId: 'user-1',
email: 'dev@example.com',
accessToken: 'access-token',
apiKey: 'k',
keyId: 'key-1',
signedInAt: 1754000000000
});
const mutation = mockClient({
revokeApiKey: jest.fn(() => unwrappable({ revokeApiKey: { result: true } }))
});

await expect(signOut({ store, authEndpoint: AUTH_ENDPOINT })).resolves.toBe(true);
expect(mutation.revokeApiKey).toHaveBeenCalledWith({ input: { keyId: 'key-1' } }, expect.anything());
expect(loadSession(store)).toBeNull();
});

it('clears the session even when the revoke fails', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
saveSession(store, {
userId: 'user-1',
email: 'dev@example.com',
accessToken: 'access-token',
keyId: 'key-1',
signedInAt: 1754000000000
});
mockClient({ revokeApiKey: jest.fn(() => failing(new Error('offline'))) });

await expect(signOut({ store, authEndpoint: AUTH_ENDPOINT })).resolves.toBe(true);
expect(loadSession(store)).toBeNull();
warn.mockRestore();
});

it('is a no-op when signed out', async () => {
await expect(signOut({ store, authEndpoint: AUTH_ENDPOINT })).resolves.toBe(false);
expect(createClient).not.toHaveBeenCalled();
});
});
Loading
Loading