diff --git a/AGENTS.md b/AGENTS.md index 5bd4d42..72a964e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,7 @@ This repository publishes the open-source server, console, integration catalog, - Keep production source files at or below 500 lines. - Keep functions focused on one ownership boundary. - Propagate unexpected errors to the CLI or request boundary. +- Issue API tokens only from a Better Auth session: 32 random bytes returned once and stored as a SHA-256 hash in `api_tokens`, honoring `ttl_seconds` with `null` meaning no expiry. Resolve bearer values as API tokens before session validation. - Commit the operation and receipt before enqueueing. Log a failed enqueue as `operation.enqueue_failed` and still return the receipt; answer a `UNIQUE(agent_id, seq_no)` conflict with `PREV_HASH_MISMATCH` carrying the stored chain hash. - Keep technical claims executable through tests or generated validation. - Keep the mirrored Python package, distribution, and CLI version in `sdks/python/elydora/_version.py`; Setuptools metadata must read that literal dynamically. @@ -65,8 +66,8 @@ This repository publishes the open-source server, console, integration catalog, - Honor valid `Retry-After` delay-seconds and HTTP-date values, and release retryable responses before sleeping. - Avoid compatibility shims without a named public contract. - Preserve the minimum runtime versions declared by each package. -- Keep private keys and API tokens out of process arguments and generated setup commands. Accept them through hidden terminal input or owner-only credential files. -- Keep Console instruction generation limited to agent identity and public configuration. Adapter commands use hidden CLI prompts; direct SDK examples read named runtime environment variables. Expose credentials through explicit copy controls only. +- Keep private keys and API tokens out of process arguments. CLIs accept them through hidden terminal input or owner-only credential files. +- Console instruction generation accepts agent identity, public configuration, and the issued credentials. Native hook commands store both credentials in owner-only files, install through the SDK file flags, and delete the files; direct SDK examples read named runtime environment variables. Credential values also remain available through explicit copy controls. - Persist credential-bearing files through owner-only same-directory temporary files and atomic rename. - Read runtime config, private keys, status cache, chain state, and error logs through physical-file descriptors with identity checks. Write cache and validated chain state atomically, and append error logs through no-follow owner-only descriptors. Preserve rollback artifacts when recovery cannot safely restore an original file and include the recovery path in the surfaced error. - Go CLI install credentials come from terminal-echo-disabled input or physical owner-only files containing one UTF-8 line of at most 64 KiB. Runtime config, private key, and audit script form one rollback-capable transaction, and generated runtimes validate file identity, size, and Unix permissions before reads. diff --git a/README.md b/README.md index e533f80..169c2d6 100644 --- a/README.md +++ b/README.md @@ -259,13 +259,13 @@ Return the current session and user profile. Requires a valid session token or c #### `POST /v1/auth/token` -Issue a long-lived API token. +Issue an opaque API token for SDK and hook use. Requires a Better Auth session; the token is returned once and only its SHA-256 hash is stored. | Field | Type | Required | Description | |-------|------|----------|-------------| -| `ttl_seconds` | integer \| null | no | Token lifetime in seconds. `null` for non-expiring. | +| `ttl_seconds` | integer \| null | no | Token lifetime in seconds, from 1 to 31536000. `null` or omitted for non-expiring. | -**Response:** `{ "token": "", "expires_at": | null }` +**Response:** `{ "token": "", "expires_at": | null }` --- diff --git a/docker-compose.yml b/docker-compose.yml index cae06a0..a4b0f6b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -231,6 +231,17 @@ services: CREATE INDEX IF NOT EXISTS idx_exports_org_status ON exports (org_id, status); + CREATE TABLE IF NOT EXISTS api_tokens ( + token_id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (user_id), + org_id TEXT NOT NULL REFERENCES organizations (org_id), + token_hash TEXT NOT NULL UNIQUE, + issued_at BIGINT NOT NULL, + expires_at BIGINT + ); + + CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens (user_id); + INSERT INTO schema_versions (version, applied_at, description) VALUES (1, EXTRACT(EPOCH FROM NOW())::BIGINT * 1000, 'Initial schema') ON CONFLICT DO NOTHING; @@ -238,6 +249,10 @@ services: INSERT INTO schema_versions (version, applied_at, description) VALUES (2, EXTRACT(EPOCH FROM NOW())::BIGINT * 1000, 'Require supported agent integration type') ON CONFLICT DO NOTHING; + + INSERT INTO schema_versions (version, applied_at, description) + VALUES (3, EXTRACT(EPOCH FROM NOW())::BIGINT * 1000, 'Opaque API tokens') + ON CONFLICT DO NOTHING; " restart: on-failure diff --git a/packages/console/e2e/agent-registration-contract.spec.ts b/packages/console/e2e/agent-registration-contract.spec.ts index 5d28036..59c3f94 100644 --- a/packages/console/e2e/agent-registration-contract.spec.ts +++ b/packages/console/e2e/agent-registration-contract.spec.ts @@ -4,6 +4,8 @@ import { INTEGRATION_CATALOG, INTEGRATION_TYPES, } from '@/features/agent-registration/integrations'; +import enRegistration from '@/i18n/en.json'; +import zhRegistration from '@/i18n/zh.json'; import { assertRegistrationResponse, RegistrationContractError, @@ -43,35 +45,161 @@ test('keeps the complete integration catalog in canonical order', () => { expect(INTEGRATION_TYPES).toEqual(EXPECTED_INTEGRATIONS); expect(INTEGRATION_CATALOG.map(({ id }) => id)).toEqual(EXPECTED_INTEGRATIONS); expect(new Set(INTEGRATION_CATALOG.map(({ id }) => id)).size).toBe(19); - expect(INTEGRATION_CATALOG.filter(({ mode }) => mode === 'adapter')).toHaveLength(15); + expect(INTEGRATION_CATALOG.filter(({ mode }) => mode === 'hooks')).toHaveLength(15); expect(INTEGRATION_CATALOG.filter(({ mode }) => mode === 'sdk')).toHaveLength(4); + expect(Object.fromEntries(INTEGRATION_CATALOG.map(({ id, name }) => [id, name]))) + .toMatchObject({ + codex: 'Codex CLI', + cursor: 'Cursor CLI', + grok: 'Grok Build', + kimi: 'Kimi Code', + }); }); -test('generates every adapter command with the verified SDK flag contract', () => { - for (const integration of INTEGRATION_CATALOG.filter(({ mode }) => mode === 'adapter')) { +test('keeps every native hook activation plan aligned with its provider contract', () => { + const plans = Object.fromEntries( + INTEGRATION_CATALOG + .filter(({ mode }) => mode === 'hooks') + .map(({ id, postInstall }) => [id, postInstall]), + ); + + expect(plans).toEqual({ + augment: { + kind: 'sequence', + steps: [{ key: 'startSession', command: 'auggie' }], + }, + claudecode: { + kind: 'sequence', + steps: [{ key: 'openHooks', command: '/hooks' }], + }, + cline: { + kind: 'sequence', + steps: [{ key: 'startTask' }], + }, + codex: { + kind: 'sequence', + steps: [{ key: 'reviewAndTrustHooks', command: '/hooks' }], + }, + copilot: { + kind: 'sequence', + steps: [ + { key: 'restartSession', command: '/restart' }, + { key: 'inspectEnvironment', command: '/env' }, + ], + }, + cursor: { + kind: 'sequence', + steps: [{ key: 'inspectCursorHooks' }], + }, + droid: { + kind: 'sequence', + steps: [{ key: 'openHooks', command: '/hooks' }], + }, + gemini: { + kind: 'sequence', + steps: [{ key: 'listHooks', command: '/hooks list' }], + }, + grok: { + kind: 'sequence', + steps: [{ key: 'inspectGrokConfig', command: 'grok inspect --json' }], + }, + kimi: { + kind: 'sequence', + steps: [{ key: 'startSession', command: 'kimi' }], + }, + kirocli: { + kind: 'alternatives', + steps: [ + { key: 'launchKiroV2', command: 'kiro-cli --agent elydora-audit', variant: 'V2' }, + { key: 'launchKiroV3', command: 'kiro-cli --v3', variant: 'V3' }, + ], + }, + kiroide: { + kind: 'sequence', + steps: [{ key: 'inspectKiroIdeHooks' }], + }, + letta: { + kind: 'sequence', + steps: [{ key: 'openHooks', command: '/hooks' }], + }, + opencode: { + kind: 'sequence', + steps: [{ key: 'startSession', command: 'opencode' }], + }, + qwen: { + kind: 'sequence', + steps: [{ key: 'listHooks', command: '/hooks list' }], + }, + }); +}); + +test('translates every activation step in English and Chinese', () => { + const stepKeys = [...new Set( + INTEGRATION_CATALOG.flatMap(({ postInstall }) => ( + postInstall?.steps.map(({ key }) => key) ?? [] + )), + )].sort(); + + expect(Object.keys(enRegistration.agentRegistration.postInstallSteps).sort()).toEqual(stepKeys); + expect(Object.keys(zhRegistration.agentRegistration.postInstallSteps).sort()).toEqual(stepKeys); +}); + +test('generates every native hook command with the verified SDK flag contract', () => { + for (const integration of INTEGRATION_CATALOG.filter(({ mode }) => mode === 'hooks')) { for (const language of SDK_LANGUAGES) { const instructions = buildInstallInstructions(language, { integration, identity: credentials, baseUrl: 'https://api.example.com', + privateKey: 'private-contract-key', + token: 'token-contract', }); expect(instructions.setup).toContain(`--agent ${integration.id}`); expect(instructions.setup).toContain("'https://api.example.com'"); - expect(instructions.setup).not.toContain('private-key'); - expect(instructions.setup).not.toContain('private_key'); - expect(instructions.setup).not.toContain('--token'); - expect(instructions.setup).not.toContain('token-contract'); - expect(instructions.secretDelivery).toBe('hidden-prompts'); + expect(instructions.setup).toContain("printf '%s' 'k=$(mktemp \"$HOME/.elydora-agent-contract.XXXXXX\") && trap '\\''rm -f \"$k\"'\\'' EXIT INT TERM && t=$(mktemp \"$HOME/.elydora-agent-contract.XXXXXX\") && trap '\\''rm -f \"$k\" \"$t\"'\\'' EXIT INT TERM && "); + expect(instructions.setup).toContain("printf '\\''%s\\n'\\'' '\\''private-contract-key'\\'' > \"$k\""); + expect(instructions.setup).toContain("printf '\\''%s\\n'\\'' '\\''token-contract'\\'' > \"$t\""); + expect(instructions.setup).not.toContain('chmod'); + expect(instructions.setup.endsWith("' | sh")).toBe(true); + expect(instructions.secretDelivery).toBe('embedded'); + expect(instructions.postInstall).toEqual(integration.postInstall); if (language === 'go') { - expect(instructions.setup).toContain("--org-id 'org-contract'"); - expect(instructions.setup).toContain("--agent-id 'agent-contract'"); - expect(instructions.setup).toContain("--base-url 'https://api.example.com'"); + expect(instructions.setup).toContain("--org-id '\\''org-contract'\\''"); + expect(instructions.setup).toContain("--agent-id '\\''agent-contract'\\''"); + expect(instructions.setup).toContain("--base-url '\\''https://api.example.com'\\''"); + expect(instructions.setup).toContain('--private-key-file "$k" --token-file "$t"'); } else { - expect(instructions.setup).toContain("--org_id 'org-contract'"); - expect(instructions.setup).toContain("--agent_id 'agent-contract'"); - expect(instructions.setup).toContain("--base_url 'https://api.example.com'"); + expect(instructions.setup).toContain("--org_id '\\''org-contract'\\''"); + expect(instructions.setup).toContain("--agent_id '\\''agent-contract'\\''"); + expect(instructions.setup).toContain("--base_url '\\''https://api.example.com'\\''"); + expect(instructions.setup).toContain('--private_key_file "$k" --token_file "$t"'); + } + } + } +}); + +test('generates PowerShell native hook commands with the same SDK flag contract', () => { + for (const integration of INTEGRATION_CATALOG.filter(({ mode }) => mode === 'hooks')) { + for (const language of SDK_LANGUAGES) { + const instructions = buildInstallInstructions(language, { + integration, + identity: credentials, + baseUrl: 'https://api.example.com', + privateKey: 'private-contract-key', + token: 'token-contract', + }, 'powershell'); + + expect(instructions.setup).toContain(`--agent ${integration.id}`); + expect(instructions.setup).toContain("$k = Join-Path $HOME '.elydora-agent-contract.key'; $t = Join-Path $HOME '.elydora-agent-contract.token'; try { New-Item -ItemType File -Force -Path $k, $t | Out-Null; if ($IsLinux -or $IsMacOS) { chmod 600 $k $t; if ($LASTEXITCODE -ne 0) { throw \"chmod failed\" } } else { $u = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value; foreach ($f in $k, $t) { icacls $f /inheritance:r /grant:r \"*$($u):F\" | Out-Null; if ($LASTEXITCODE -ne 0) { throw \"icacls failed for $f\" } } }; Set-Content -LiteralPath $k -Value 'private-contract-key'; Set-Content -LiteralPath $t -Value 'token-contract'; "); + expect(instructions.setup.endsWith('; if ($LASTEXITCODE -ne 0) { throw "elydora install exited with code $LASTEXITCODE" } } finally { Remove-Item -LiteralPath $k, $t -Force -ErrorAction SilentlyContinue }')).toBe(true); + expect(instructions.secretDelivery).toBe('embedded'); + + if (language === 'go') { + expect(instructions.setup).toContain("--org-id 'org-contract' --agent-id 'agent-contract' --kid 'agent-contract-key-1' --base-url 'https://api.example.com' --private-key-file $k --token-file $t;"); + } else { + expect(instructions.setup).toContain("--org_id 'org-contract' --agent_id 'agent-contract' --kid 'agent-contract-key-1' --base_url 'https://api.example.com' --private_key_file $k --token_file $t;"); } } } @@ -86,11 +214,13 @@ test('generates direct SDK setup and operation code for each language', () => { integration: integration!, identity: credentials, baseUrl: 'https://api.example.com', + privateKey: 'private-contract-key', + token: 'token-contract', }); expect(instructions.setup).toContain('agent-contract'); expect(instructions.setup).toContain('ELYDORA_PRIVATE_KEY'); expect(instructions.setup).toContain('ELYDORA_API_TOKEN'); - expect(instructions.setup).not.toContain('private-key'); + expect(instructions.setup).not.toContain('private-contract-key'); expect(instructions.setup).not.toContain('token-contract'); expect(instructions.usage).toContain('ai.tool_use'); expect(instructions.verify).toBeUndefined(); @@ -110,6 +240,8 @@ test('quotes shell metacharacters and rejects unsupported command values', () => orgId: '$(Write-Output injected)', }, baseUrl: 'https://api.example.com/`whoami`', + privateKey: 'private-contract-key', + token: 'token-contract', }); expect(instructions.setup).toContain("'$(Write-Output injected)'"); expect(instructions.setup).toContain("'https://api.example.com/`whoami`'"); @@ -118,7 +250,23 @@ test('quotes shell metacharacters and rejects unsupported command values', () => integration: integration!, identity: { ...credentials, orgId: "org-'unsafe" }, baseUrl: 'https://api.example.com', + privateKey: 'private-contract-key', + token: 'token-contract', })).toThrow('Organization ID cannot be represented'); + expect(() => buildInstallInstructions('node', { + integration: integration!, + identity: credentials, + baseUrl: 'https://api.example.com', + privateKey: "key-'unsafe", + token: 'token-contract', + })).toThrow('Private key cannot be represented'); + expect(() => buildInstallInstructions('node', { + integration: integration!, + identity: { ...credentials, agentId: 'agent contract' }, + baseUrl: 'https://api.example.com', + privateKey: 'private-contract-key', + token: 'token-contract', + })).toThrow('Agent ID cannot be used as a credential file name.'); }); test('classifies malformed successful responses as blocking contract failures', () => { diff --git a/packages/console/e2e/agent-registration.spec.ts b/packages/console/e2e/agent-registration.spec.ts index f6837de..567733e 100644 --- a/packages/console/e2e/agent-registration.spec.ts +++ b/packages/console/e2e/agent-registration.spec.ts @@ -110,7 +110,7 @@ async function mockBackend( }); } -test('registers Grok atomically and renders verified adapter commands', async ({ page }) => { +test('registers Grok atomically and renders verified native hook commands', async ({ page }) => { const observed: ObservedRequest[] = []; const unexpected: string[] = []; const runtimeErrors: string[] = []; @@ -123,10 +123,26 @@ test('registers Grok atomically and renders verified adapter commands', async ({ await page.goto('/agents'); await page.getByRole('button', { name: 'Register Agent' }).click(); await waitForRenderedDialog(page); - await expect(page.getByRole('button', { name: 'Augment Code' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'OpenAI Codex' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Kimi CLI' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Qwen Code' })).toBeVisible(); + const dialog = page.getByRole('dialog'); + await dialog.focus(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + const firstIntegration = page.getByRole('button', { name: 'Augment Code' }); + await expect(firstIntegration).toBeFocused(); + expect(await firstIntegration.evaluate((element) => { + const style = window.getComputedStyle(element); + return { width: style.outlineWidth, style: style.outlineStyle }; + })).toEqual({ width: '2px', style: 'solid' }); + const hookGroup = page.getByRole('region', { name: 'Native hooks' }); + await expect(hookGroup.getByRole('button')).toHaveCount(15); + await expect(hookGroup.getByText('HOOKS', { exact: true })).toHaveCount(15); + await expect(hookGroup.getByRole('button', { name: 'Codex CLI' })).toBeVisible(); + await expect(hookGroup.getByRole('button', { name: 'Kimi Code' })).toBeVisible(); + await expect(hookGroup.getByRole('button', { name: 'Grok Build' })).toBeVisible(); + await expect(hookGroup.getByRole('button', { name: 'Cursor CLI' })).toBeVisible(); + const sdkGroup = page.getByRole('region', { name: 'Custom SDK' }); + await expect(sdkGroup.getByRole('button')).toHaveCount(4); + await expect(sdkGroup.getByText('HOOKS', { exact: true })).toHaveCount(0); await page.screenshot({ path: 'test-results/agent-registration-catalog.png', fullPage: true }); await page.getByRole('button', { name: 'Grok Build' }).click(); await page.getByLabel('Display Name').fill('Production Grok'); @@ -151,29 +167,58 @@ test('registers Grok atomically and renders verified adapter commands', async ({ const setup = page.locator('pre'); await expect(setup).toContainText('npx @elydora/sdk install --agent grok'); - await expect(setup).toContainText(`--org_id '${ORG_ID}'`); - await expect(setup).not.toContainText(TOKEN); - await expect(setup).not.toContainText(privateKey); - await expect(setup).not.toContainText('--token'); - await expect(setup).not.toContainText('--private_key'); - await expect(page.getByText('The CLI requests both values with terminal echo disabled.')).toBeVisible(); + await expect(setup).toContainText(`--org_id '\\''${ORG_ID}'\\''`); + await expect(setup).toContainText(`'${TOKEN}'`); + await expect(setup).toContainText(`'${privateKey}'`); + await expect(setup).toContainText('mktemp "$HOME/.elydora-'); + await expect(setup).toContainText('--private_key_file "$k" --token_file "$t"'); + await expect(page.getByText( + 'The command stores both credentials in owner-only files, installs the hook, then deletes them.', + )).toBeVisible(); + await expect(page.getByText( + 'Confirm the Elydora PreToolUse, PostToolUse, and PostToolUseFailure hooks.', + )).toBeVisible(); + await expect(page.getByText('grok inspect --json', { exact: true })).toBeVisible(); await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); await page.getByRole('button', { name: 'Copy setup command' }).click(); await expect(page.getByRole('button', { name: 'Copy setup command' })).toHaveText('Copied'); const copiedSetup = await page.evaluate(() => navigator.clipboard.readText()); - expect(copiedSetup).not.toContain(TOKEN); - expect(copiedSetup).not.toContain(privateKey); + expect(copiedSetup).toContain(TOKEN); + expect(copiedSetup).toContain(privateKey); + const activationCopy = page.getByRole('button', { + name: 'Copy activation command: grok inspect --json', + }); + await activationCopy.click(); + await expect(activationCopy).toHaveText('Copied'); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe('grok inspect --json'); await page.getByRole('tab', { name: 'go' }).click(); await expect(setup).toContainText('elydora install --agent grok'); - await expect(setup).toContainText(`--org-id '${ORG_ID}'`); - await expect(setup).toContainText("--agent-id 'agent-"); - await expect(setup).not.toContainText('--private-key'); - await expect(setup).not.toContainText('--token'); + await expect(setup).toContainText(`--org-id '\\''${ORG_ID}'\\''`); + await expect(setup).toContainText("--agent-id '\\''agent-"); + await expect(setup).toContainText('--private-key-file "$k" --token-file "$t"'); + await page.getByRole('tab', { name: 'PowerShell' }).click(); + await expect(setup).toContainText("$k = Join-Path $HOME '.elydora-agent-"); + await expect(setup).toContainText('--private-key-file $k --token-file $t;'); + await page.getByRole('tab', { name: 'sh', exact: true }).click(); + await expect(setup).toContainText("printf '%s' 'k=$(mktemp "); await page.screenshot({ path: 'test-results/agent-registration-grok.png', fullPage: true }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole('dialog').locator(':scope > div').last().evaluate( + (content) => content.scrollTo(0, content.scrollHeight), + ); + await expect(page.getByText('grok inspect --json', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { + name: 'Copy activation command: grok inspect --json', + })).toBeVisible(); + expect(await page.evaluate( + () => document.documentElement.scrollWidth > window.innerWidth, + )).toBe(false); + await page.screenshot({ path: 'test-results/agent-registration-grok-mobile.png', fullPage: true }); + const registrationRequests = observed.filter(({ path }) => path === '/v1/agents/register'); expect(registrationRequests).toHaveLength(1); expect(registrationRequests[0]?.method).toBe('POST'); @@ -249,6 +294,40 @@ test('renders custom SDK setup with runtime secret references', async ({ page }) expect(runtimeErrors).toEqual([]); }); +test('renders Kiro CLI versions as activation alternatives', async ({ page }) => { + const observed: ObservedRequest[] = []; + const unexpected: string[] = []; + const runtimeErrors: string[] = []; + page.on('pageerror', (error) => runtimeErrors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') runtimeErrors.push(message.text()); + }); + await mockBackend(page, observed, unexpected); + + await page.goto('/agents'); + await page.getByRole('button', { name: 'Register Agent' }).click(); + await page.getByRole('button', { name: 'Kiro CLI' }).click(); + await page.getByLabel('Display Name').fill('Production Kiro'); + await page.locator('form').getByRole('button', { name: 'Register Agent' }).click(); + await page.getByRole('button', { name: 'Issue API Token' }).click(); + await expect(page.getByText(TOKEN)).toBeVisible(); + await page.getByRole('button', { name: 'Continue' }).click(); + + const alternatives = page.getByRole('list', { name: 'Post-install steps' }); + await expect(alternatives).toHaveJSProperty('tagName', 'UL'); + await expect(alternatives.locator('li')).toHaveCount(2); + await expect(alternatives.getByText('V2', { exact: true })).toBeVisible(); + await expect(alternatives.getByText('V3', { exact: true })).toBeVisible(); + await expect(alternatives.getByText('kiro-cli --agent elydora-audit', { exact: true })).toBeVisible(); + await expect(alternatives.getByText('kiro-cli --v3', { exact: true })).toBeVisible(); + await page.screenshot({ path: 'test-results/agent-registration-kiro.png', fullPage: true }); + + expect(observed.filter(({ path }) => path === '/v1/agents/register')).toHaveLength(1); + expect(observed.filter(({ path }) => path === '/v1/auth/token')).toHaveLength(1); + expect(unexpected).toEqual([]); + expect(runtimeErrors).toEqual([]); +}); + test('loads the Chinese registration resources', async ({ page }) => { const observed: ObservedRequest[] = []; const unexpected: string[] = []; @@ -263,11 +342,21 @@ test('loads the Chinese registration resources', async ({ page }) => { await page.goto('/agents'); await expect(page.locator('html')).toHaveAttribute('lang', 'zh'); await page.getByRole('button', { name: '注册代理' }).click(); - await expect(page.getByText('CLI 适配器')).toBeVisible(); + await expect(page.getByText('原生 Hooks')).toBeVisible(); await expect(page.getByText('自定义 SDK')).toBeVisible(); - await expect(page.getByRole('button', { name: 'OpenAI Codex' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Codex CLI' })).toBeVisible(); + await page.getByRole('button', { name: 'Kimi Code' }).click(); + await page.getByLabel('显示名称').fill('生产 Kimi'); + await page.locator('form').getByRole('button', { name: '注册代理' }).click(); + await page.getByRole('button', { name: '签发 API 令牌' }).click(); + await expect(page.getByText(TOKEN)).toBeVisible(); + await page.getByRole('button', { name: '继续' }).click(); + await expect(page.getByText('启动新的 Kimi Code 会话。')).toBeVisible(); + await expect(page.getByText('kimi', { exact: true })).toBeVisible(); - expect(observed.filter(({ path }) => path === '/v1/agents')).toHaveLength(1); + expect(observed.filter(({ path }) => path === '/v1/agents')).toHaveLength(2); + expect(observed.filter(({ path }) => path === '/v1/agents/register')).toHaveLength(1); + expect(observed.filter(({ path }) => path === '/v1/auth/token')).toHaveLength(1); expect(unexpected).toEqual([]); expect(runtimeErrors).toEqual([]); }); diff --git a/packages/console/src/app/globals.css b/packages/console/src/app/globals.css index ff37fc5..d94f07d 100644 --- a/packages/console/src/app/globals.css +++ b/packages/console/src/app/globals.css @@ -353,3 +353,21 @@ body { .check-fail { color: #dc2626; } + +/* Focus-visible styles for keyboard navigation (WCAG 2.1 AA) */ +:focus-visible { + outline: 2px solid var(--ink-color); + outline-offset: 2px; +} + +.btn-brutalist:focus-visible, +.btn-ghost:focus-visible { + outline: 2px solid var(--bg-color); + outline-offset: 2px; + box-shadow: 0 0 0 4px var(--ink-color); +} + +.sidebar-nav-item:focus-visible { + outline: 2px solid #EAEAE5; + outline-offset: -2px; +} diff --git a/packages/console/src/components/AgentDetailClient.tsx b/packages/console/src/components/AgentDetailClient.tsx index ebf064e..45ad483 100644 --- a/packages/console/src/components/AgentDetailClient.tsx +++ b/packages/console/src/components/AgentDetailClient.tsx @@ -12,7 +12,7 @@ import Modal from '@/components/ui/Modal'; import Link from 'next/link'; import type { AgentKey, Operation } from '@elydora/shared'; import { useTranslation } from 'react-i18next'; -import { ADAPTER_INTEGRATION_IDS } from '@/features/agent-registration/integrations'; +import { HOOK_INTEGRATION_IDS } from '@/features/agent-registration/integrations'; export default function AgentDetailClient({ agentId }: { agentId: string }) { const { t } = useTranslation(); @@ -37,7 +37,7 @@ export default function AgentDetailClient({ agentId }: { agentId: string }) { const agent = agentData?.agent; const supportsFreeze = agent - ? ADAPTER_INTEGRATION_IDS.has(agent.integration_type) + ? HOOK_INTEGRATION_IDS.has(agent.integration_type) : false; const keys = agentData?.keys ?? []; const operations = opsData?.operations ?? []; diff --git a/packages/console/src/components/agent-registration/ConnectStep.tsx b/packages/console/src/components/agent-registration/ConnectStep.tsx index 62c8588..e20f29f 100644 --- a/packages/console/src/components/agent-registration/ConnectStep.tsx +++ b/packages/console/src/components/agent-registration/ConnectStep.tsx @@ -7,7 +7,9 @@ import type { IntegrationCatalogItem } from '@/features/agent-registration/integ import { buildInstallInstructions, SDK_LANGUAGES, + SHELLS, type SdkLanguage, + type Shell, } from '@/features/agent-registration/install-instructions'; import CopyControl from './CopyControl'; @@ -28,6 +30,7 @@ export default function ConnectStep({ }: ConnectStepProps) { const { t } = useTranslation(); const [language, setLanguage] = useState('node'); + const [shell, setShell] = useState('posix'); const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:8787'; const instructions = useMemo( () => buildInstallInstructions(language, { @@ -38,9 +41,12 @@ export default function ConnectStep({ orgId: credentials.orgId, }, baseUrl, - }), - [baseUrl, credentials.agentId, credentials.kid, credentials.orgId, integration, language], + privateKey: credentials.privateKey, + token, + }, shell), + [baseUrl, credentials, integration, language, shell, token], ); + const PostInstallList = instructions.postInstall?.kind === 'alternatives' ? 'ul' : 'ol'; return (
@@ -66,10 +72,29 @@ export default function ConnectStep({ ))}
+ {integration.mode === 'hooks' && ( +
+ {SHELLS.map((item) => ( + + ))} +
+ )} +

- {integration.mode === 'adapter' ? t('agentRegistration.install') : t('agentRegistration.configure')} + {integration.mode === 'hooks' ? t('agentRegistration.install') : t('agentRegistration.configure')}

- {instructions.postInstall.map((step, index) => ( -
  • - {index + 1} - {step} + + {instructions.postInstall.steps.map((step, index) => ( +
  • + + {step.variant ?? String(index + 1).padStart(2, '0')} + + + + {t(`agentRegistration.postInstallSteps.${step.key}`, { + integration: integration.name, + })} + + {step.command && ( + {step.command} + )} + + {step.command && ( + + )}
  • ))} - + )}
    diff --git a/packages/console/src/components/agent-registration/CredentialsStep.tsx b/packages/console/src/components/agent-registration/CredentialsStep.tsx index 9d6a9a8..65fafbf 100644 --- a/packages/console/src/components/agent-registration/CredentialsStep.tsx +++ b/packages/console/src/components/agent-registration/CredentialsStep.tsx @@ -35,6 +35,7 @@ function CredentialRow({ label, value }: { readonly label: string; readonly valu
    ); } + export default function CredentialsStep({ credentials, token, @@ -44,7 +45,7 @@ export default function CredentialsStep({ onContinue, }: CredentialsStepProps) { const { t } = useTranslation(); - const [expiration, setExpiration] = useState('24hours'); + const [expiration, setExpiration] = useState('neverExpire'); const [customDays, setCustomDays] = useState(''); const [validationError, setValidationError] = useState(null); diff --git a/packages/console/src/components/agent-registration/IntegrationStep.tsx b/packages/console/src/components/agent-registration/IntegrationStep.tsx index b099e08..4ad2d16 100644 --- a/packages/console/src/components/agent-registration/IntegrationStep.tsx +++ b/packages/console/src/components/agent-registration/IntegrationStep.tsx @@ -32,11 +32,18 @@ function IntegrationGroup({ key={integration.id} type="button" onClick={() => onSelect(integration)} - className="group flex items-center justify-between gap-3 px-3 py-3 text-left border-b border-border hover:bg-surface focus-visible:bg-surface focus-visible:outline-none" + className="group flex items-center justify-between gap-3 px-3 py-3 text-left border-b border-border hover:bg-surface focus-visible:bg-surface" > {integration.name} -
    ); } + export default function IntegrationStep({ onSelect }: IntegrationStepProps) { return (
    - +
    ); diff --git a/packages/console/src/features/agent-registration/install-instructions.ts b/packages/console/src/features/agent-registration/install-instructions.ts index d43edd7..ef32d2d 100644 --- a/packages/console/src/features/agent-registration/install-instructions.ts +++ b/packages/console/src/features/agent-registration/install-instructions.ts @@ -1,8 +1,10 @@ -import type { IntegrationCatalogItem } from './integrations'; +import type { IntegrationCatalogItem, PostInstallPlan } from './integrations'; export const SDK_LANGUAGES = ['node', 'python', 'go'] as const; export type SdkLanguage = (typeof SDK_LANGUAGES)[number]; -type SecretDelivery = 'hidden-prompts' | 'environment'; +export const SHELLS = ['posix', 'powershell'] as const; +export type Shell = (typeof SHELLS)[number]; +type SecretDelivery = 'embedded' | 'environment'; interface AgentInstallIdentity { readonly agentId: string; @@ -14,13 +16,15 @@ interface InstructionInput { readonly integration: IntegrationCatalogItem; readonly identity: AgentInstallIdentity; readonly baseUrl: string; + readonly privateKey: string; + readonly token: string; } export interface InstallInstructions { readonly setup: string; readonly usage?: string; readonly verify?: string; - readonly postInstall?: readonly string[]; + readonly postInstall?: PostInstallPlan; readonly secretDelivery: SecretDelivery; } @@ -31,42 +35,97 @@ function shellQuoted(value: string, label: string): string { return `'${value}'`; } -function adapterCommand( +const INSTALLER: Record = { + node: 'npx @elydora/sdk install', + python: 'elydora install', + go: 'elydora install', +}; +const BOOTSTRAP: Partial> = { + python: 'python -m pip install elydora', + go: 'go install github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora@latest', +}; + +interface CommandValues { + readonly agent: string; + readonly orgId: string; + readonly agentId: string; + readonly kid: string; + readonly baseUrl: string; +} + +function credentialFileStem(agentId: string): string { + if (!/^[A-Za-z0-9._-]+$/u.test(agentId)) { + throw new Error('Agent ID cannot be used as a credential file name.'); + } + return `.elydora-${agentId}`; +} + +function installArguments( language: SdkLanguage, - { integration, identity, baseUrl }: InstructionInput, + values: CommandValues, + keyFile: string, + tokenFile: string, ): string { - const values = { + if (language === 'go') { + return `--agent ${values.agent} --org-id ${values.orgId} --agent-id ${values.agentId} --kid ${values.kid} --base-url ${values.baseUrl} --private-key-file ${keyFile} --token-file ${tokenFile}`; + } + return `--agent ${values.agent} --org_id ${values.orgId} --agent_id ${values.agentId} --kid ${values.kid} --base_url ${values.baseUrl} --private_key_file ${keyFile} --token_file ${tokenFile}`; +} + +function posixInstall( + language: SdkLanguage, + values: CommandValues, + stem: string, + privateKey: string, + token: string, +): string { + const template = `"$HOME/${stem}.XXXXXX"`; + const script = [ + `k=$(mktemp ${template})`, + `trap 'rm -f "$k"' EXIT INT TERM`, + `t=$(mktemp ${template})`, + `trap 'rm -f "$k" "$t"' EXIT INT TERM`, + `printf '%s\\n' ${shellQuoted(privateKey, 'Private key')} > "$k"`, + `printf '%s\\n' ${shellQuoted(token, 'API token')} > "$t"`, + `${INSTALLER[language]} ${installArguments(language, values, '"$k"', '"$t"')}`, + ].join(' && '); + return `printf '%s' '${script.replace(/'/gu, "'\\''")}' | sh`; +} + +function powershellInstall( + language: SdkLanguage, + values: CommandValues, + stem: string, + privateKey: string, + token: string, +): string { + const paths = `$k = Join-Path $HOME '${stem}.key'; $t = Join-Path $HOME '${stem}.token'`; + const create = 'New-Item -ItemType File -Force -Path $k, $t | Out-Null'; + const protect = 'if ($IsLinux -or $IsMacOS) { chmod 600 $k $t; if ($LASTEXITCODE -ne 0) { throw "chmod failed" } } else { $u = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value; foreach ($f in $k, $t) { icacls $f /inheritance:r /grant:r "*$($u):F" | Out-Null; if ($LASTEXITCODE -ne 0) { throw "icacls failed for $f" } } }'; + const store = `Set-Content -LiteralPath $k -Value ${shellQuoted(privateKey, 'Private key')}; Set-Content -LiteralPath $t -Value ${shellQuoted(token, 'API token')}`; + const install = `${INSTALLER[language]} ${installArguments(language, values, '$k', '$t')}`; + const status = 'if ($LASTEXITCODE -ne 0) { throw "elydora install exited with code $LASTEXITCODE" }'; + return `${paths}; try { ${create}; ${protect}; ${store}; ${install}; ${status} } finally { Remove-Item -LiteralPath $k, $t -Force -ErrorAction SilentlyContinue }`; +} + +function hookInstallerCommand( + language: SdkLanguage, + { integration, identity, baseUrl, privateKey, token }: InstructionInput, + shell: Shell, +): string { + const values: CommandValues = { agent: integration.id, orgId: shellQuoted(identity.orgId, 'Organization ID'), agentId: shellQuoted(identity.agentId, 'Agent ID'), kid: shellQuoted(identity.kid, 'Key ID'), baseUrl: shellQuoted(baseUrl, 'API base URL'), }; - - if (language === 'go') { - return [ - 'go install github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora@latest', - `elydora install --agent ${values.agent} --org-id ${values.orgId} --agent-id ${values.agentId} --kid ${values.kid} --base-url ${values.baseUrl}`, - ].join('\n'); - } - - const executable = language === 'node' - ? 'npx @elydora/sdk install' - : 'python -m pip install elydora\nelydora install'; - return `${executable} --agent ${values.agent} --org_id ${values.orgId} --agent_id ${values.agentId} --kid ${values.kid} --base_url ${values.baseUrl}`; -} - -function postInstallSteps(integration: IntegrationCatalogItem): readonly string[] | undefined { - if (integration.postInstall === 'review-hooks') { - return ['Open the agent and run /hooks to review the active Elydora hooks.']; - } - if (integration.postInstall === 'start-kiro') { - return [ - 'Kiro CLI v2: kiro-cli --agent elydora-audit', - 'Kiro CLI v3: kiro-cli --v3', - ]; - } - return undefined; + const stem = credentialFileStem(identity.agentId); + const command = shell === 'powershell' + ? powershellInstall(language, values, stem, privateKey, token) + : posixInstall(language, values, stem, privateKey, token); + const bootstrap = BOOTSTRAP[language]; + return bootstrap ? `${bootstrap}\n${command}` : command; } function sdkSetup(language: SdkLanguage, input: InstructionInput): string { @@ -95,7 +154,7 @@ client.set_kid(${JSON.stringify(identity.kid)})`; } if (language === 'go') { - return `go get github.com/Elydora-Infrastructure/Elydora-Go-SDK + return `go get github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2 requireEnv := func(name string) string { value, ok := os.LookupEnv(name) @@ -176,13 +235,14 @@ await client.submitOperation(operation);`; export function buildInstallInstructions( language: SdkLanguage, input: InstructionInput, + shell: Shell = 'posix', ): InstallInstructions { - if (input.integration.mode === 'adapter') { + if (input.integration.mode === 'hooks') { return { - setup: adapterCommand(language, input), + setup: hookInstallerCommand(language, input, shell), verify: language === 'node' ? 'npx @elydora/sdk status' : 'elydora status', - postInstall: postInstallSteps(input.integration), - secretDelivery: 'hidden-prompts', + postInstall: input.integration.postInstall, + secretDelivery: 'embedded', }; } diff --git a/packages/console/src/features/agent-registration/integrations.ts b/packages/console/src/features/agent-registration/integrations.ts index deee213..418466b 100644 --- a/packages/console/src/features/agent-registration/integrations.ts +++ b/packages/console/src/features/agent-registration/integrations.ts @@ -5,35 +5,169 @@ import { export { INTEGRATION_TYPES }; -export type IntegrationMode = 'adapter' | 'sdk'; -export type PostInstallAction = 'review-hooks' | 'start-kiro'; +export type IntegrationMode = 'hooks' | 'sdk'; +export type PostInstallStepKey = + | 'inspectEnvironment' + | 'inspectCursorHooks' + | 'inspectGrokConfig' + | 'inspectKiroIdeHooks' + | 'launchKiroV2' + | 'launchKiroV3' + | 'listHooks' + | 'openHooks' + | 'reviewAndTrustHooks' + | 'restartSession' + | 'startSession' + | 'startTask'; + +export interface PostInstallStep { + readonly key: PostInstallStepKey; + readonly command?: string; + readonly variant?: string; +} + +export interface PostInstallPlan { + readonly kind: 'sequence' | 'alternatives'; + readonly steps: readonly PostInstallStep[]; +} export interface IntegrationCatalogItem { readonly id: IntegrationType; readonly name: string; readonly mode: IntegrationMode; - readonly postInstall?: PostInstallAction; + readonly postInstall?: PostInstallPlan; } const INTEGRATION_METADATA: Record< IntegrationType, Omit > = { - augment: { name: 'Augment Code', mode: 'adapter' }, - claudecode: { name: 'Claude Code', mode: 'adapter' }, - cline: { name: 'Cline', mode: 'adapter' }, - codex: { name: 'OpenAI Codex', mode: 'adapter', postInstall: 'review-hooks' }, - copilot: { name: 'GitHub Copilot CLI', mode: 'adapter' }, - cursor: { name: 'Cursor', mode: 'adapter' }, - droid: { name: 'Factory Droid', mode: 'adapter', postInstall: 'review-hooks' }, - gemini: { name: 'Gemini CLI', mode: 'adapter' }, - grok: { name: 'Grok Build', mode: 'adapter' }, - kimi: { name: 'Kimi CLI', mode: 'adapter', postInstall: 'review-hooks' }, - kirocli: { name: 'Kiro CLI', mode: 'adapter', postInstall: 'start-kiro' }, - kiroide: { name: 'Kiro IDE', mode: 'adapter' }, - letta: { name: 'Letta Code', mode: 'adapter' }, - opencode: { name: 'OpenCode', mode: 'adapter' }, - qwen: { name: 'Qwen Code', mode: 'adapter', postInstall: 'review-hooks' }, + augment: { + name: 'Augment Code', + mode: 'hooks', + postInstall: { + kind: 'sequence', + steps: [{ key: 'startSession', command: 'auggie' }], + }, + }, + claudecode: { + name: 'Claude Code', + mode: 'hooks', + postInstall: { + kind: 'sequence', + steps: [{ key: 'openHooks', command: '/hooks' }], + }, + }, + cline: { + name: 'Cline', + mode: 'hooks', + postInstall: { + kind: 'sequence', + steps: [{ key: 'startTask' }], + }, + }, + codex: { + name: 'Codex CLI', + mode: 'hooks', + postInstall: { + kind: 'sequence', + steps: [{ key: 'reviewAndTrustHooks', command: '/hooks' }], + }, + }, + copilot: { + name: 'GitHub Copilot CLI', + mode: 'hooks', + postInstall: { + kind: 'sequence', + steps: [ + { key: 'restartSession', command: '/restart' }, + { key: 'inspectEnvironment', command: '/env' }, + ], + }, + }, + cursor: { + name: 'Cursor CLI', + mode: 'hooks', + postInstall: { + kind: 'sequence', + steps: [{ key: 'inspectCursorHooks' }], + }, + }, + droid: { + name: 'Factory Droid', + mode: 'hooks', + postInstall: { + kind: 'sequence', + steps: [{ key: 'openHooks', command: '/hooks' }], + }, + }, + gemini: { + name: 'Gemini CLI', + mode: 'hooks', + postInstall: { + kind: 'sequence', + steps: [{ key: 'listHooks', command: '/hooks list' }], + }, + }, + grok: { + name: 'Grok Build', + mode: 'hooks', + postInstall: { + kind: 'sequence', + steps: [{ key: 'inspectGrokConfig', command: 'grok inspect --json' }], + }, + }, + kimi: { + name: 'Kimi Code', + mode: 'hooks', + postInstall: { + kind: 'sequence', + steps: [{ key: 'startSession', command: 'kimi' }], + }, + }, + kirocli: { + name: 'Kiro CLI', + mode: 'hooks', + postInstall: { + kind: 'alternatives', + steps: [ + { key: 'launchKiroV2', command: 'kiro-cli --agent elydora-audit', variant: 'V2' }, + { key: 'launchKiroV3', command: 'kiro-cli --v3', variant: 'V3' }, + ], + }, + }, + kiroide: { + name: 'Kiro IDE', + mode: 'hooks', + postInstall: { + kind: 'sequence', + steps: [{ key: 'inspectKiroIdeHooks' }], + }, + }, + letta: { + name: 'Letta Code', + mode: 'hooks', + postInstall: { + kind: 'sequence', + steps: [{ key: 'openHooks', command: '/hooks' }], + }, + }, + opencode: { + name: 'OpenCode', + mode: 'hooks', + postInstall: { + kind: 'sequence', + steps: [{ key: 'startSession', command: 'opencode' }], + }, + }, + qwen: { + name: 'Qwen Code', + mode: 'hooks', + postInstall: { + kind: 'sequence', + steps: [{ key: 'listHooks', command: '/hooks list' }], + }, + }, enterprise: { name: 'Enterprise Agent', mode: 'sdk' }, gui: { name: 'GUI Agent', mode: 'sdk' }, sdk: { name: 'SDK', mode: 'sdk' }, @@ -43,6 +177,6 @@ const INTEGRATION_METADATA: Record< export const INTEGRATION_CATALOG: readonly IntegrationCatalogItem[] = INTEGRATION_TYPES.map((id) => ({ id, ...INTEGRATION_METADATA[id] })); -export const ADAPTER_INTEGRATION_IDS: ReadonlySet = new Set( - INTEGRATION_CATALOG.filter(({ mode }) => mode === 'adapter').map(({ id }) => id), +export const HOOK_INTEGRATION_IDS: ReadonlySet = new Set( + INTEGRATION_CATALOG.filter(({ mode }) => mode === 'hooks').map(({ id }) => id), ); diff --git a/packages/console/src/i18n/en.json b/packages/console/src/i18n/en.json index f80c0d6..9c28df3 100644 --- a/packages/console/src/i18n/en.json +++ b/packages/console/src/i18n/en.json @@ -330,13 +330,15 @@ "connect": "Connect" }, "integrationGroups": { - "adapter": "CLI adapters", + "hooks": "Native hooks", "sdk": "Custom SDK" }, + "hookBadge": "HOOKS", "integration": "Integration", "copySetup": "Copy setup command", "copyUsage": "Copy operation example", "copyVerify": "Copy verification command", + "copyPostInstall": "Copy activation command: {{command}}", "copyPrivateKey": "Copy private key", "copyApiToken": "Copy API token", "copyFailed": "Copy failed.", @@ -346,9 +348,24 @@ "record": "Record an operation", "verify": "Verify", "postInstall": "Post-install steps", + "postInstallSteps": { + "inspectCursorHooks": "Open Customize → Hooks and confirm Elydora in the Hooks output channel.", + "inspectEnvironment": "Confirm the Elydora hooks in the loaded environment.", + "inspectGrokConfig": "Confirm the Elydora PreToolUse, PostToolUse, and PostToolUseFailure hooks.", + "inspectKiroIdeHooks": "Open Agent Hooks in the Kiro panel and confirm Elydora Audit is enabled.", + "launchKiroV2": "Launch the Elydora audit agent in Kiro CLI v2.", + "launchKiroV3": "Start Kiro CLI v3 with the global Elydora hooks.", + "listHooks": "List the loaded Elydora hooks.", + "openHooks": "Open the hook manager and review the Elydora hooks.", + "reviewAndTrustHooks": "Review and trust both Elydora command hooks.", + "restartSession": "Restart the current {{integration}} session.", + "startSession": "Start a new {{integration}} session.", + "startTask": "Start a new {{integration}} task." + }, "sdkLanguage": "SDK language", + "shell": "Shell", "secretDelivery": { - "hidden-prompts": "The CLI requests both values with terminal echo disabled.", + "embedded": "The command stores both credentials in owner-only files, installs the hook, then deletes them.", "environment": "Set ELYDORA_PRIVATE_KEY and ELYDORA_API_TOKEN in the runtime environment." } }, diff --git a/packages/console/src/i18n/zh.json b/packages/console/src/i18n/zh.json index 4674a78..5e7d96e 100644 --- a/packages/console/src/i18n/zh.json +++ b/packages/console/src/i18n/zh.json @@ -330,13 +330,15 @@ "connect": "连接" }, "integrationGroups": { - "adapter": "CLI 适配器", + "hooks": "原生 Hooks", "sdk": "自定义 SDK" }, + "hookBadge": "HOOKS", "integration": "集成", "copySetup": "复制安装命令", "copyUsage": "复制操作示例", "copyVerify": "复制验证命令", + "copyPostInstall": "复制激活命令:{{command}}", "copyPrivateKey": "复制私钥", "copyApiToken": "复制 API 令牌", "copyFailed": "复制失败。", @@ -346,9 +348,24 @@ "record": "记录操作", "verify": "验证", "postInstall": "安装后步骤", + "postInstallSteps": { + "inspectCursorHooks": "打开 Customize → Hooks,在 Hooks 输出通道中确认 Elydora。", + "inspectEnvironment": "确认已加载环境包含 Elydora hooks。", + "inspectGrokConfig": "确认 Elydora PreToolUse、PostToolUse 与 PostToolUseFailure hooks。", + "inspectKiroIdeHooks": "打开 Kiro 面板中的 Agent Hooks,确认 Elydora Audit 已启用。", + "launchKiroV2": "在 Kiro CLI v2 中启动 Elydora audit agent。", + "launchKiroV3": "启动 Kiro CLI v3 并加载全局 Elydora hooks。", + "listHooks": "列出已加载的 Elydora hooks。", + "openHooks": "打开 hook 管理器并检查 Elydora hooks。", + "reviewAndTrustHooks": "检查并信任两个 Elydora command hooks。", + "restartSession": "重启当前 {{integration}} 会话。", + "startSession": "启动新的 {{integration}} 会话。", + "startTask": "启动新的 {{integration}} 任务。" + }, "sdkLanguage": "SDK 语言", + "shell": "终端", "secretDelivery": { - "hidden-prompts": "CLI 会在关闭终端回显后请求这两项凭据。", + "embedded": "命令会把两项凭据写入仅所有者可读的文件,安装 hook 后立即删除。", "environment": "请在运行环境中设置 ELYDORA_PRIVATE_KEY 与 ELYDORA_API_TOKEN。" } }, diff --git a/packages/server/migrations/003_api_tokens.sql b/packages/server/migrations/003_api_tokens.sql new file mode 100644 index 0000000..1b7a17b --- /dev/null +++ b/packages/server/migrations/003_api_tokens.sql @@ -0,0 +1,24 @@ +-- Opaque API tokens stored as SHA-256 hashes. + +BEGIN; + +CREATE TABLE IF NOT EXISTS api_tokens ( + token_id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (user_id), + org_id TEXT NOT NULL REFERENCES organizations (org_id), + token_hash TEXT NOT NULL UNIQUE, + issued_at BIGINT NOT NULL, + expires_at BIGINT +); + +CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens (user_id); + +INSERT INTO schema_versions (version, applied_at, description) +VALUES ( + 3, + EXTRACT(EPOCH FROM NOW())::BIGINT * 1000, + 'Opaque API tokens' +) +ON CONFLICT (version) DO NOTHING; + +COMMIT; diff --git a/packages/server/src/i18n/messages.ts b/packages/server/src/i18n/messages.ts index 4fd772d..3405c85 100644 --- a/packages/server/src/i18n/messages.ts +++ b/packages/server/src/i18n/messages.ts @@ -90,8 +90,8 @@ const messages: Record> = { zh: 'Bearer \u4ee4\u724c\u4e3a\u7a7a', }, 'auth.ttlMustBePositive': { - en: 'ttl_seconds must be a positive number', - zh: 'ttl_seconds \u5fc5\u987b\u4e3a\u6b63\u6570', + en: 'ttl_seconds must be a whole number of seconds from 1 to 31536000', + zh: 'ttl_seconds 必须是 1 到 31536000 之间的整数秒', }, // --------------------------------------------------------------------------- @@ -121,6 +121,14 @@ const messages: Record> = { en: 'ttl_seconds must be a number or null', zh: 'ttl_seconds \u5fc5\u987b\u4e3a\u6570\u5b57\u6216 null', }, + 'auth.issueRequiresSession': { + en: 'API token issuance requires a session credential.', + zh: '签发 API 令牌需要使用会话凭据。', + }, + 'auth.organizationRequiredForApiToken': { + en: 'Organization setup is required before issuing an API token.', + zh: '签发 API 令牌前需要完成组织设置。', + }, // --------------------------------------------------------------------------- // Agent routes (src/routes/agents.ts) diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts index 0a6871d..8e821bd 100644 --- a/packages/server/src/middleware/auth.ts +++ b/packages/server/src/middleware/auth.ts @@ -1,14 +1,10 @@ /** - * Better Auth authentication middleware. + * Authentication middleware for opaque API tokens and Better Auth sessions. * - * Validates requests via Better Auth sessions (cookie or bearer token). - * On success, extracts `org_id`, `role`, and user `id` (actor) from the - * session and stores them in the Hono context variables for downstream - * handlers. - * - * Supports: - * - Session cookies (browser / console) - * - Bearer tokens via Better Auth's bearer plugin (SDK clients) + * A bearer value is resolved as a hashed API token first; anything else is + * validated as a Better Auth session (cookie or bearer session token). + * On success the org_id, role, actor, and credential kind are stored in the + * Hono context variables for downstream handlers. */ import type { MiddlewareHandler } from 'hono'; @@ -16,13 +12,8 @@ import type { RbacRole } from '../shared/index.js'; import type { Env, AppVariables } from '../types.js'; import { AppError } from './error-handler.js'; import { createAuth } from '../lib/auth.js'; +import { authenticateApiToken } from '../services/api-token-service.js'; -/** - * Authentication middleware. - * - * Uses Better Auth's getSession to validate the request via session - * cookie or bearer token. Sets org_id, role, and actor on the context. - */ export const authMiddleware: MiddlewareHandler<{ Bindings: Env; Variables: AppVariables; @@ -32,6 +23,19 @@ export const authMiddleware: MiddlewareHandler<{ throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingHeader' }); } + const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''; + if (bearerToken) { + const apiToken = await authenticateApiToken(c.env.ELYDORA_DB, bearerToken); + if (apiToken) { + c.set('org_id', apiToken.org_id); + c.set('role', apiToken.role); + c.set('actor', apiToken.user_id); + c.set('auth_token_type', 'api'); + await next(); + return; + } + } + let session: Awaited['api']['getSession']>>; try { @@ -57,6 +61,7 @@ export const authMiddleware: MiddlewareHandler<{ c.set('org_id', user.org_id ?? ''); c.set('role', ((user.role ?? 'readonly_investigator') as RbacRole)); c.set('actor', user.id); + c.set('auth_token_type', 'session'); await next(); }; diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index 6a33e2d..4e46233 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -2,7 +2,7 @@ * Auth routes — registration, login, profile retrieval, and token refresh. * * /register and /login are public (no auth required). - * /me, /refresh, and /token require a valid Better Auth session. + * /me and /refresh require a valid Better Auth session or API token; /token requires a session. * * Uses Better Auth for session management while preserving the existing * API surface (endpoint paths and response formats). @@ -11,6 +11,8 @@ import { Hono } from 'hono'; import type { Env, AppVariables } from '../types.js'; import { authMiddleware } from '../middleware/auth.js'; +import { AppError } from '../middleware/error-handler.js'; +import { issueApiToken, MAX_TTL_SECONDS } from '../services/api-token-service.js'; import { getMe } from '../services/auth-service.js'; import { createAuth } from '../lib/auth.js'; import { getMessage } from '../i18n/messages.js'; @@ -169,46 +171,32 @@ auth.post('/refresh', authMiddleware, async (c) => { }); // --------------------------------------------------------------------------- -// POST /v1/auth/token — Issue an API token with custom TTL (auth required) +// POST /v1/auth/token — Issue an opaque API token (session required) // --------------------------------------------------------------------------- auth.post('/token', authMiddleware, async (c) => { + if (c.get('auth_token_type') !== 'session') { + throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.issueRequiresSession' }); + } + const body = await c.req.json(); const { ttl_seconds } = body; if (ttl_seconds !== null && ttl_seconds !== undefined) { - if (typeof ttl_seconds !== 'number' || ttl_seconds <= 0) { - const lang = c.get('lang') ?? 'en'; - return c.json({ error: { code: 'VALIDATION_ERROR', message: getMessage('auth.ttlMustBeNumber', lang), request_id: '' } }, 400); + if (typeof ttl_seconds !== 'number') { + throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.ttlMustBeNumber' }); + } + if (!Number.isInteger(ttl_seconds) || ttl_seconds <= 0 || ttl_seconds > MAX_TTL_SECONDS) { + throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.ttlMustBePositive' }); } } - // Get the current session token — Better Auth sessions have a fixed - // lifetime configured in the auth instance (7 days by default). - // The ttl_seconds parameter is accepted for API compatibility but - // session lifetime is governed by Better Auth's session config. - const betterAuthInstance = createAuth( - process.env.DATABASE_URL!, - c.env.BETTER_AUTH_SECRET, - c.env.BETTER_AUTH_URL, - c.env.ALLOWED_ORIGINS, - ); - - const session = await betterAuthInstance.api.getSession({ - headers: c.req.raw.headers, - }); - - if (!session) { - const lang = c.get('lang') ?? 'en'; - return c.json({ error: { code: 'UNAUTHORIZED', message: getMessage('auth.invalidSession', lang), request_id: '' } }, 401); + const orgId = c.get('org_id'); + if (!orgId) { + throw new AppError(409, 'VALIDATION_ERROR', { key: 'auth.organizationRequiredForApiToken' }); } - const sessionData = session.session as { token?: string; expiresAt?: Date }; - const token = sessionData.token ?? ''; - const expiresAt = sessionData.expiresAt - ? Math.floor(new Date(sessionData.expiresAt).getTime() / 1000) - : Math.floor(Date.now() / 1000) + 604800; - - return c.json({ token, expires_at: expiresAt }, 200); + const issued = await issueApiToken(c.env.ELYDORA_DB, c.get('actor'), orgId, ttl_seconds ?? null); + return c.json({ token: issued.token, expires_at: issued.expires_at }, 200); }); export { auth }; diff --git a/packages/server/src/services/api-token-service.ts b/packages/server/src/services/api-token-service.ts new file mode 100644 index 0000000..87868cb --- /dev/null +++ b/packages/server/src/services/api-token-service.ts @@ -0,0 +1,87 @@ +/** + * Opaque API tokens: the raw value is returned once; only its SHA-256 hash is stored. + */ + +import { createHash, randomBytes } from 'node:crypto'; +import type { Database } from '../adapters/interfaces.js'; +import type { RbacRole } from '../shared/index.js'; +import { generateUUIDv7 } from '../utils/uuid.js'; + +export const MAX_TTL_SECONDS = 31_536_000; + +interface ApiTokenAuthRow { + readonly token_id: string; + readonly user_id: string; + readonly org_id: string; + readonly expires_at: number | null; + readonly user_org_id: string | null; + readonly user_role: RbacRole; + readonly user_status: string; +} + +export interface AuthenticatedApiToken { + readonly token_id: string; + readonly user_id: string; + readonly org_id: string; + readonly role: RbacRole; +} + +export interface IssuedApiToken { + readonly token: string; + readonly token_id: string; + readonly expires_at: number | null; +} + +function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +export async function authenticateApiToken( + db: Database, + rawToken: string, +): Promise { + const nowSeconds = Math.floor(Date.now() / 1000); + const row = await db + .prepare( + `SELECT t.token_id, t.user_id, t.org_id, t.expires_at, + u.org_id AS user_org_id, u.role AS user_role, u.status AS user_status + FROM api_tokens AS t + JOIN users AS u ON u.user_id = t.user_id + WHERE t.token_hash = ?`, + ) + .bind(hashToken(rawToken)) + .first(); + + if (!row) return null; + if (row.expires_at !== null && row.expires_at <= nowSeconds) return null; + if (row.user_status !== 'active' || row.user_org_id !== row.org_id) return null; + + return { + token_id: row.token_id, + user_id: row.user_id, + org_id: row.org_id, + role: row.user_role, + }; +} + +export async function issueApiToken( + db: Database, + userId: string, + orgId: string, + ttlSeconds: number | null, +): Promise { + const nowSeconds = Math.floor(Date.now() / 1000); + const expiresAt = ttlSeconds === null ? null : nowSeconds + ttlSeconds; + const tokenId = generateUUIDv7(); + const token = randomBytes(32).toString('base64url'); + + await db + .prepare( + `INSERT INTO api_tokens (token_id, user_id, org_id, token_hash, issued_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .bind(tokenId, userId, orgId, hashToken(token), nowSeconds, expiresAt) + .run(); + + return { token, token_id: tokenId, expires_at: expiresAt }; +} diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 46e975c..5ce18a5 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -62,6 +62,9 @@ export interface AppVariables { /** Actor identifier extracted from the verified session */ actor: string; + /** Credential kind that authenticated the request */ + auth_token_type: 'api' | 'session'; + /** Detected language from Accept-Language header (set by i18n middleware) */ lang: import('./i18n/messages.js').Lang; } diff --git a/packages/server/test/api-token.test.mjs b/packages/server/test/api-token.test.mjs new file mode 100644 index 0000000..e45109e --- /dev/null +++ b/packages/server/test/api-token.test.mjs @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import test from 'node:test'; + +import { Hono } from 'hono'; +import { authMiddleware } from '../dist/middleware/auth.js'; +import { auth } from '../dist/routes/auth.js'; +import { authenticateApiToken, issueApiToken } from '../dist/services/api-token-service.js'; + +const NOW = Math.floor(Date.now() / 1000); + +class FakeDatabase { + constructor(firstResult = null) { + this.firstResult = firstResult; + this.statements = []; + } + + prepare(sql) { + const database = this; + const statement = { + sql, + values: [], + bind(...values) { + statement.values = values; + return statement; + }, + async first() { + database.statements.push({ sql, values: statement.values }); + return database.firstResult; + }, + async run() { + database.statements.push({ sql, values: statement.values }); + return { success: true }; + }, + }; + return statement; + } +} + +function liveRow(overrides = {}) { + return { + token_id: 'token-1', + user_id: 'user-1', + org_id: 'org-1', + expires_at: null, + user_org_id: 'org-1', + user_role: 'integration_engineer', + user_status: 'active', + ...overrides, + }; +} + +const ENV = { ELYDORA_DB: null, BETTER_AUTH_SECRET: 'secret', BETTER_AUTH_URL: 'http://localhost', ALLOWED_ORIGINS: '' }; + +test('issues a 32-byte token and stores only its hash', async () => { + const db = new FakeDatabase(); + const issued = await issueApiToken(db, 'user-1', 'org-1', 3600); + + assert.equal(Buffer.from(issued.token, 'base64url').length, 32); + assert.ok(issued.expires_at >= NOW + 3600); + const [insert] = db.statements; + assert.match(insert.sql, /INSERT INTO api_tokens/); + assert.equal(insert.values[3], createHash('sha256').update(issued.token).digest('hex')); + assert.equal(insert.values[5], issued.expires_at); + assert.ok(!insert.values.includes(issued.token)); +}); + +test('a null ttl issues a token without expiry', async () => { + const db = new FakeDatabase(); + const issued = await issueApiToken(db, 'user-1', 'org-1', null); + assert.equal(issued.expires_at, null); + assert.equal(db.statements[0].values[5], null); +}); + +test('authenticates a live token through its hash', async () => { + const db = new FakeDatabase(liveRow()); + const result = await authenticateApiToken(db, 'raw-token'); + assert.deepEqual(result, { token_id: 'token-1', user_id: 'user-1', org_id: 'org-1', role: 'integration_engineer' }); + assert.equal(db.statements[0].values[0], createHash('sha256').update('raw-token').digest('hex')); +}); + +test('rejects unknown, expired, suspended, and re-homed tokens', async () => { + for (const row of [ + null, + liveRow({ expires_at: NOW - 1 }), + liveRow({ user_status: 'suspended' }), + liveRow({ user_org_id: 'org-2' }), + ]) { + assert.equal(await authenticateApiToken(new FakeDatabase(row), 'raw-token'), null); + } + assert.notEqual(await authenticateApiToken(new FakeDatabase(liveRow({ expires_at: NOW + 60 })), 'raw-token'), null); +}); + +test('the middleware resolves an API bearer token before Better Auth', async () => { + const app = new Hono(); + app.get('/whoami', authMiddleware, (c) => c.json({ + org_id: c.get('org_id'), + role: c.get('role'), + actor: c.get('actor'), + auth_token_type: c.get('auth_token_type'), + })); + + const response = await app.request( + '/whoami', + { headers: { Authorization: 'Bearer raw-token' } }, + { ...ENV, ELYDORA_DB: new FakeDatabase(liveRow()) }, + ); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + org_id: 'org-1', + role: 'integration_engineer', + actor: 'user-1', + auth_token_type: 'api', + }); +}); + +test('an API token cannot issue another API token', async () => { + const app = new Hono(); + app.onError((error, c) => c.json({ error: { code: error.errorCode, key: error.messageKey } }, error.statusCode)); + app.route('/v1/auth', auth); + + const response = await app.request( + '/v1/auth/token', + { + method: 'POST', + headers: { Authorization: 'Bearer raw-token', 'Content-Type': 'application/json' }, + body: JSON.stringify({ ttl_seconds: null }), + }, + { ...ENV, ELYDORA_DB: new FakeDatabase(liveRow()) }, + ); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: { code: 'VALIDATION_ERROR', key: 'auth.issueRequiresSession' } }); +});