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
144 changes: 142 additions & 2 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ addWorkspaceOption(
'',
'Next steps:',
`1) Start server: workgraph serve -w "${result.workspacePath}"`,
`2) Register first agent: workgraph agent register agent-1 -w "${result.workspacePath}" --token ${result.bootstrapTrustToken}`,
`2) Preferred registration flow: workgraph agent request agent-1 -w "${result.workspacePath}" --role roles/admin.md`,
` Approve request: workgraph agent review agent-1 -w "${result.workspacePath}" --decision approved --actor admin-approver`,
` Bootstrap fallback: workgraph agent register agent-1 -w "${result.workspacePath}" --token ${result.bootstrapTrustToken}`,
`3) Create first thread: workgraph thread create "First coordinated task" -w "${result.workspacePath}" --goal "Validate onboarding flow" --actor agent-1`,
];
}
Expand Down Expand Up @@ -513,7 +515,7 @@ addWorkspaceOption(
addWorkspaceOption(
agentCmd
.command('register <name>')
.description('Register an agent using the bootstrap trust token')
.description('Register an agent using bootstrap token fallback (legacy/hybrid mode)')
.option('--token <token>', 'Bootstrap trust token (or WORKGRAPH_TRUST_TOKEN env)')
.option('--role <role>', 'Role slug/path override (default from trust token)')
.option('--capabilities <items>', 'Comma-separated extra capabilities')
Expand Down Expand Up @@ -546,6 +548,144 @@ addWorkspaceOption(
`Presence: ${result.presence.path}`,
`Policy party: ${result.policyParty.id}`,
`Bootstrap token: ${result.trustTokenPath} [${result.trustTokenStatus}]`,
...(result.credential ? [`Credential: ${result.credential.id} [${result.credential.status}]`] : []),
...(result.apiKey ? [`API key (store securely, shown once): ${result.apiKey}`] : []),
],
)
);

addWorkspaceOption(
agentCmd
.command('request <name>')
.description('Submit an approval-based agent registration request')
.option('--role <role>', 'Requested role slug/path (default: roles/contributor.md)')
.option('--capabilities <items>', 'Comma-separated requested extra capabilities')
.option('-a, --actor <name>', 'Actor submitting the request')
.option('--note <text>', 'Optional request note')
.option('--json', 'Emit structured JSON output')
).action((name, opts) =>
runCommand(
opts,
() => {
const workspacePath = resolveWorkspacePath(opts);
return workgraph.agent.submitRegistrationRequest(workspacePath, name, {
role: opts.role,
capabilities: csv(opts.capabilities),
actor: opts.actor,
note: opts.note,
});
},
(result) => [
`Submitted registration request for ${result.agentName}`,
`Request: ${result.request.path}`,
`Requested role: ${result.requestedRolePath}`,
`Requested capabilities: ${result.requestedCapabilities.join(', ') || 'none'}`,
],
)
);

addWorkspaceOption(
agentCmd
.command('review <requestRef>')
.description('Approve or reject a pending registration request')
.requiredOption('--decision <decision>', 'approved | rejected')
.option('-a, --actor <name>', 'Reviewer actor', DEFAULT_ACTOR)
.option('--role <role>', 'Approved role slug/path (for approved decisions)')
.option('--capabilities <items>', 'Comma-separated approved extra capabilities')
.option('--scopes <items>', 'Comma-separated credential scopes (defaults to approved capabilities)')
.option('--expires-at <isoDate>', 'Optional credential expiry ISO date')
.option('--note <text>', 'Optional review note')
.option('--json', 'Emit structured JSON output')
).action((requestRef, opts) =>
runCommand(
opts,
() => {
const workspacePath = resolveWorkspacePath(opts);
const decision = String(opts.decision ?? '').trim().toLowerCase();
if (decision !== 'approved' && decision !== 'rejected') {
throw new Error('Invalid --decision value. Expected approved|rejected.');
}
return workgraph.agent.reviewRegistrationRequest(
workspacePath,
requestRef,
opts.actor,
decision,
{
role: opts.role,
capabilities: csv(opts.capabilities),
scopes: csv(opts.scopes),
expiresAt: opts.expiresAt,
note: opts.note,
},
);
},
(result) => [
`Reviewed request: ${result.request.path}`,
`Decision: ${result.decision}`,
`Approval record: ${result.approval.path}`,
...(result.policyParty
? [`Policy party: ${result.policyParty.id} (${result.policyParty.roles.join(', ')})`]
: []),
...(result.credential ? [`Credential: ${result.credential.id} [${result.credential.status}]`] : []),
...(result.apiKey ? [`API key (store securely, shown once): ${result.apiKey}`] : []),
],
)
);

addWorkspaceOption(
agentCmd
.command('credential-list')
.description('List issued agent credentials')
.option('--actor <name>', 'Filter by actor id')
.option('--json', 'Emit structured JSON output')
).action((opts) =>
runCommand(
opts,
() => {
const workspacePath = resolveWorkspacePath(opts);
const credentials = workgraph.agent.listAgentCredentials(workspacePath, opts.actor);
return {
credentials,
count: credentials.length,
};
},
(result) => {
if (result.credentials.length === 0) return ['No credentials found.'];
return [
...result.credentials.map((credential) =>
`${credential.id} actor=${credential.actor} status=${credential.status} scopes=${credential.scopes.join(', ') || 'none'}`
),
`${result.count} credential(s)`,
];
},
)
);

addWorkspaceOption(
agentCmd
.command('credential-revoke <credentialId>')
.description('Revoke an issued credential')
.option('-a, --actor <name>', 'Actor revoking the credential', DEFAULT_ACTOR)
.option('--reason <text>', 'Optional revocation reason')
.option('--json', 'Emit structured JSON output')
).action((credentialId, opts) =>
runCommand(
opts,
() => {
const workspacePath = resolveWorkspacePath(opts);
return {
credential: workgraph.agent.revokeAgentCredential(
workspacePath,
credentialId,
opts.actor,
opts.reason,
),
};
},
(result) => [
`Revoked credential: ${result.credential.id}`,
`Actor: ${result.credential.actor}`,
`Status: ${result.credential.status}`,
],
)
);
Expand Down
22 changes: 21 additions & 1 deletion packages/cli/src/cli/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type JsonCapableOptions = {
workspace?: string;
vault?: string;
sharedVault?: string;
apiKey?: string;
dryRun?: boolean;
__dryRunWorkspace?: string;
__dryRunWorkspaceRoot?: string;
Expand All @@ -20,6 +21,7 @@ export function addWorkspaceOption<T extends Command>(command: T): T {
.option('-w, --workspace <path>', 'Workgraph workspace path')
.option('--vault <path>', 'Alias for --workspace')
.option('--shared-vault <path>', 'Shared vault path (e.g. mounted via Tailscale)')
.option('--api-key <token>', 'Agent credential API key (or WORKGRAPH_API_KEY env)')
.option('--dry-run', 'Execute against a temporary workspace copy and discard changes');
}

Expand Down Expand Up @@ -194,7 +196,11 @@ export async function runCommand<T>(
renderText: (result: T) => string[],
): Promise<void> {
try {
const result = await action();
const credentialToken = readCredentialToken(opts);
const result = await workgraph.auth.runWithAuthContext({
...(credentialToken ? { credentialToken } : {}),
source: 'cli',
}, () => action());
const dryRunMetadata = opts.dryRun
? {
dryRun: true,
Expand Down Expand Up @@ -240,3 +246,17 @@ function cleanupDryRunSandbox(opts: JsonCapableOptions): void {
delete opts.__dryRunWorkspace;
delete opts.__dryRunOriginal;
}

function readCredentialToken(opts: JsonCapableOptions): string | undefined {
const fromOption = readNonEmptyString((opts as { apiKey?: unknown }).apiKey);
if (fromOption) return fromOption;
const fromEnv = readNonEmptyString(process.env.WORKGRAPH_AGENT_API_KEY)
?? readNonEmptyString(process.env.WORKGRAPH_API_KEY);
return fromEnv;
}

function readNonEmptyString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
78 changes: 77 additions & 1 deletion packages/control-api/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,18 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { store as storeModule, thread as threadModule } from '@versatly/workgraph-kernel';
import {
agent as agentModule,
store as storeModule,
thread as threadModule,
workspace as workspaceModule,
} from '@versatly/workgraph-kernel';
import { startWorkgraphServer } from './server.js';

const agent = agentModule;
const store = storeModule;
const thread = threadModule;
const workspace = workspaceModule;

let workspacePath: string;

Expand Down Expand Up @@ -337,4 +344,73 @@ describe('workgraph server REST API', () => {
await handle.close();
}
});

it('enforces strict credential identity for mutating REST endpoints', async () => {
const init = workspace.initWorkspace(workspacePath, { createReadme: false, createBases: false });
const registration = agent.registerAgent(workspacePath, 'api-admin', {
token: init.bootstrapTrustToken,
capabilities: ['thread:create', 'thread:update', 'thread:complete'],
});
expect(registration.apiKey).toBeDefined();

const serverConfigPath = path.join(workspacePath, '.workgraph', 'server.json');
const serverConfig = JSON.parse(fs.readFileSync(serverConfigPath, 'utf-8')) as Record<string, unknown>;
serverConfig.auth = {
mode: 'strict',
allowUnauthenticatedFallback: false,
};
fs.writeFileSync(serverConfigPath, `${JSON.stringify(serverConfig, null, 2)}\n`, 'utf-8');

const handle = await startWorkgraphServer({
workspacePath,
host: '127.0.0.1',
port: 0,
defaultActor: 'system',
});
try {
const unauthorized = await fetch(`${handle.baseUrl}/api/threads`, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
title: 'Strict denied',
goal: 'Missing credential should fail',
}),
});
expect(unauthorized.status).toBe(403);

const spoofed = await fetch(`${handle.baseUrl}/api/threads`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${registration.apiKey}`,
},
body: JSON.stringify({
title: 'Strict spoofed',
goal: 'Credential actor mismatch should fail',
actor: 'spoofed-actor',
}),
});
expect(spoofed.status).toBe(403);

const authorized = await fetch(`${handle.baseUrl}/api/threads`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${registration.apiKey}`,
},
body: JSON.stringify({
title: 'Strict allowed',
goal: 'Valid credential actor should pass',
}),
});
expect(authorized.status).toBe(201);
const body = await authorized.json() as { ok: boolean; thread: { path: string } };
expect(body.ok).toBe(true);
expect(body.thread.path).toBe('threads/strict-allowed.md');
} finally {
await handle.close();
}
});
});
Loading