diff --git a/kiloclaw/controller/src/bootstrap.test.ts b/kiloclaw/controller/src/bootstrap.test.ts index ac3281f09..1d04642dd 100644 --- a/kiloclaw/controller/src/bootstrap.test.ts +++ b/kiloclaw/controller/src/bootstrap.test.ts @@ -7,6 +7,7 @@ import { generateHooksToken, configureGitHub, runOnboardOrDoctor, + updateToolsMd1PasswordSection, buildGatewayArgs, bootstrap, } from './bootstrap'; @@ -567,6 +568,79 @@ describe('runOnboardOrDoctor', () => { }); }); +// ---- updateToolsMd1PasswordSection ---- + +describe('updateToolsMd1PasswordSection', () => { + it('adds 1Password section when OP_SERVICE_ACCOUNT_TOKEN is set', () => { + const harness = fakeDeps(); + (harness.deps.readFileSync as ReturnType).mockReturnValue('# TOOLS\n'); + + const env: Record = { + OP_SERVICE_ACCOUNT_TOKEN: 'ops_test123', + }; + + updateToolsMd1PasswordSection(env, harness.deps); + + expect(harness.writeCalls).toHaveLength(1); + expect(harness.writeCalls[0]!.data).toContain(''); + expect(harness.writeCalls[0]!.data).toContain('op vault list'); + expect(harness.writeCalls[0]!.data).toContain(''); + }); + + it('skips adding when section already present', () => { + const harness = fakeDeps(); + (harness.deps.readFileSync as ReturnType).mockReturnValue( + '# TOOLS\n\nexisting\n' + ); + + const env: Record = { + OP_SERVICE_ACCOUNT_TOKEN: 'ops_test123', + }; + + updateToolsMd1PasswordSection(env, harness.deps); + + expect(harness.writeCalls).toHaveLength(0); + }); + + it('removes stale section when token is absent', () => { + const harness = fakeDeps(); + (harness.deps.readFileSync as ReturnType).mockReturnValue( + '# TOOLS\n\nold section\n\n' + ); + + const env: Record = {}; + + updateToolsMd1PasswordSection(env, harness.deps); + + expect(harness.writeCalls).toHaveLength(1); + expect(harness.writeCalls[0]!.data).not.toContain(''); + }); + + it('no-ops when TOOLS.md does not exist', () => { + const harness = fakeDeps(); + (harness.deps.existsSync as ReturnType).mockReturnValue(false); + + const env: Record = { + OP_SERVICE_ACCOUNT_TOKEN: 'ops_test123', + }; + + updateToolsMd1PasswordSection(env, harness.deps); + + expect(harness.writeCalls).toHaveLength(0); + }); + + it('no-ops when token absent and no stale section exists', () => { + const harness = fakeDeps(); + (harness.deps.readFileSync as ReturnType).mockReturnValue('# TOOLS\n'); + + const env: Record = {}; + + updateToolsMd1PasswordSection(env, harness.deps); + + expect(harness.writeCalls).toHaveLength(0); + }); +}); + // ---- buildGatewayArgs ---- describe('buildGatewayArgs', () => { diff --git a/kiloclaw/controller/src/bootstrap.ts b/kiloclaw/controller/src/bootstrap.ts index 723e6e506..5acb4840c 100644 --- a/kiloclaw/controller/src/bootstrap.ts +++ b/kiloclaw/controller/src/bootstrap.ts @@ -442,7 +442,67 @@ export function updateToolsMdGoogleSection(env: EnvLike, deps: BootstrapDeps): v } } -// ---- Step 8: Gateway args ---- +// ---- Step 8: TOOLS.md 1Password section ---- + +const OP_MARKER_BEGIN = ''; +const OP_MARKER_END = ''; + +const OP_TOOLS_SECTION = ` +${OP_MARKER_BEGIN} +## 1Password + +The \`op\` CLI is configured with a 1Password service account. Use it to look up credentials, generate passwords, and manage vault items. + +- List vaults: \`op vault list\` +- Search items: \`op item list --vault \` +- Get a credential: \`op item get "" --vault \` +- Get specific field: \`op item get "" --fields password --vault \` +- Generate password: \`op item create --category login --title "New Login" --generate-password\` +- Run \`op --help\` for all available commands. + +**Security note:** Only access credentials the user has explicitly requested. Do not list or expose vault contents unnecessarily. +${OP_MARKER_END}`; + +/** + * Manage the 1Password section in TOOLS.md. + * + * When OP_SERVICE_ACCOUNT_TOKEN is present, append a bounded section so the + * agent knows the op CLI is available. When absent, remove any stale section. + * Idempotent: skips if the marker is already present. + */ +export function updateToolsMd1PasswordSection(env: EnvLike, deps: BootstrapDeps): void { + if (!deps.existsSync(TOOLS_MD_DEST)) return; + + const content = deps.readFileSync(TOOLS_MD_DEST, 'utf8'); + + if (env.OP_SERVICE_ACCOUNT_TOKEN) { + // 1Password configured — add section if not already present + if (!content.includes(OP_MARKER_BEGIN)) { + deps.writeFileSync(TOOLS_MD_DEST, content + OP_TOOLS_SECTION); + console.log('TOOLS.md: added 1Password section'); + } else { + console.log('TOOLS.md: 1Password section already present'); + } + } else { + // 1Password not configured — remove stale section if present + if (content.includes(OP_MARKER_BEGIN)) { + const beginIdx = content.indexOf(OP_MARKER_BEGIN); + const endIdx = content.indexOf(OP_MARKER_END); + if (beginIdx !== -1 && endIdx !== -1) { + const before = content.slice(0, beginIdx).replace(/\n+$/, '\n'); + const after = content.slice(endIdx + OP_MARKER_END.length).replace(/^\n+/, ''); + deps.writeFileSync(TOOLS_MD_DEST, before + after); + console.log('TOOLS.md: removed stale 1Password section'); + } else { + console.warn( + 'TOOLS.md: 1Password BEGIN marker found but END marker missing, skipping removal' + ); + } + } + } +} + +// ---- Step 9: Gateway args ---- /** * Build the gateway CLI arguments array. @@ -497,6 +557,7 @@ export async function bootstrap( await yieldToEventLoop(); updateToolsMdGoogleSection(env, deps); + updateToolsMd1PasswordSection(env, deps); // Write mcporter config for MCP servers (AgentCard, etc.) writeMcporterConfig(env); diff --git a/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts b/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts index 25a20ad6e..d8bdfd1a7 100644 --- a/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts +++ b/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts @@ -44,6 +44,7 @@ describe('Secret Catalog', () => { 'key', 'github', 'credit-card', + 'lock', ]); for (const entry of SECRET_CATALOG) { expect(validIcons.has(entry.icon)).toBe(true); @@ -193,9 +194,10 @@ describe('Secret Catalog', () => { it('returns all tool entries sorted by order', () => { const tools = getEntriesByCategory('tool'); - expect(tools.length).toBe(2); + expect(tools.length).toBe(3); expect(tools[0].id).toBe('github'); expect(tools[1].id).toBe('agentcard'); + expect(tools[2].id).toBe('onepassword'); }); it('returns empty array for categories with no entries', () => { @@ -220,7 +222,8 @@ describe('Secret Catalog', () => { expect(keys).toContain('githubUsername'); expect(keys).toContain('githubEmail'); expect(keys).toContain('agentcardApiKey'); - expect(keys.size).toBe(4); + expect(keys).toContain('onepasswordServiceAccountToken'); + expect(keys.size).toBe(5); }); it('returns empty set for categories with no entries', () => { diff --git a/kiloclaw/packages/secret-catalog/src/catalog.ts b/kiloclaw/packages/secret-catalog/src/catalog.ts index 388f2c3be..1fe6135f0 100644 --- a/kiloclaw/packages/secret-catalog/src/catalog.ts +++ b/kiloclaw/packages/secret-catalog/src/catalog.ts @@ -154,6 +154,28 @@ const SECRET_CATALOG_RAW = [ helpText: 'Virtual debit cards for autonomous agent spending. See setup guide for details.', helpUrl: 'https://agentcard.sh', }, + { + id: 'onepassword', + label: '1Password', + category: 'tool', + icon: 'lock', + order: 3, + fields: [ + { + key: 'onepasswordServiceAccountToken', + label: 'Service Account Token', + placeholder: 'ops_...', + placeholderConfigured: 'Enter new token to replace', + envVar: 'OP_SERVICE_ACCOUNT_TOKEN', + validationPattern: '^ops_[A-Za-z0-9_\\-]{50,1500}$', + validationMessage: + '1Password service account tokens start with ops_ followed by a long base64-encoded string.', + maxLength: 2000, + }, + ], + helpText: 'Create a service account at 1password.com with access to a dedicated vault.', + helpUrl: 'https://developer.1password.com/docs/service-accounts/get-started/', + }, ] as const satisfies readonly SecretCatalogEntry[]; // Runtime validation — fails fast at module load if catalog data is malformed @@ -192,6 +214,11 @@ export const FIELD_KEY_TO_ENTRY: ReadonlyMap = new M SECRET_CATALOG.flatMap(entry => entry.fields.map(field => [field.key, entry])) ); +/** Largest maxLength across all catalog fields (for blanket Zod schema caps) */ +export const MAX_SECRET_FIELD_LENGTH: number = Math.max( + ...SECRET_CATALOG.flatMap(entry => entry.fields.map(field => field.maxLength)) +); + /** Set of all env var names from catalog entries (for SENSITIVE_KEYS classification) */ export const ALL_SECRET_ENV_VARS: ReadonlySet = new Set( SECRET_CATALOG.flatMap(entry => entry.fields.map(field => field.envVar)) diff --git a/kiloclaw/packages/secret-catalog/src/index.ts b/kiloclaw/packages/secret-catalog/src/index.ts index 49a1dc5de..5a5927bfb 100644 --- a/kiloclaw/packages/secret-catalog/src/index.ts +++ b/kiloclaw/packages/secret-catalog/src/index.ts @@ -27,6 +27,7 @@ export { ENV_VAR_TO_FIELD_KEY, FIELD_KEY_TO_ENTRY, ALL_SECRET_ENV_VARS, + MAX_SECRET_FIELD_LENGTH, INTERNAL_SENSITIVE_ENV_VARS, getEntriesByCategory, getFieldKeysByCategory, diff --git a/kiloclaw/packages/secret-catalog/src/types.ts b/kiloclaw/packages/secret-catalog/src/types.ts index b8ec7b5df..106a38956 100644 --- a/kiloclaw/packages/secret-catalog/src/types.ts +++ b/kiloclaw/packages/secret-catalog/src/types.ts @@ -11,6 +11,7 @@ export const SecretIconKeySchema = z.enum([ 'key', 'github', 'credit-card', + 'lock', ]); /** diff --git a/kiloclaw/src/routes/kiloclaw.test.ts b/kiloclaw/src/routes/kiloclaw.test.ts index 4872cf07b..7705bd209 100644 --- a/kiloclaw/src/routes/kiloclaw.test.ts +++ b/kiloclaw/src/routes/kiloclaw.test.ts @@ -17,6 +17,7 @@ describe('buildConfiguredSecrets', () => { slack: false, github: false, agentcard: false, + onepassword: false, }); }); @@ -36,7 +37,10 @@ describe('buildConfiguredSecrets', () => { expect(partial.slack).toBe(false); const full = buildConfiguredSecrets({ - encryptedSecrets: { SLACK_BOT_TOKEN: envelope, SLACK_APP_TOKEN: envelope }, + encryptedSecrets: { + SLACK_BOT_TOKEN: envelope, + SLACK_APP_TOKEN: envelope, + }, }); expect(full.slack).toBe(true); }); @@ -107,12 +111,15 @@ describe('buildConfiguredSecrets', () => { expect(keys).toContain('telegram'); expect(keys).toContain('discord'); expect(keys).toContain('slack'); - expect(keys).toHaveLength(5); + expect(keys).toContain('onepassword'); + expect(keys).toHaveLength(6); }); it('treats null values as not configured', () => { const result = buildConfiguredSecrets({ - encryptedSecrets: { TELEGRAM_BOT_TOKEN: null as unknown as Record }, + encryptedSecrets: { + TELEGRAM_BOT_TOKEN: null as unknown as Record, + }, }); expect(result.telegram).toBe(false); }); diff --git a/src/app/(app)/claw/components/ChangelogCard.tsx b/src/app/(app)/claw/components/ChangelogCard.tsx index 4f48bf103..a30f1a95e 100644 --- a/src/app/(app)/claw/components/ChangelogCard.tsx +++ b/src/app/(app)/claw/components/ChangelogCard.tsx @@ -24,6 +24,10 @@ const DEPLOY_HINT_STYLES = { label: 'Redeploy Required', className: 'border-red-500/30 bg-red-500/15 text-red-400', }, + upgrade_required: { + label: 'Upgrade Required', + className: 'border-purple-500/30 bg-purple-500/15 text-purple-400', + }, } as const; function ChangelogRow({ entry }: { entry: ChangelogEntry }) { diff --git a/src/app/(app)/claw/components/ChangelogTab.tsx b/src/app/(app)/claw/components/ChangelogTab.tsx index 230b02a40..1d0fa7496 100644 --- a/src/app/(app)/claw/components/ChangelogTab.tsx +++ b/src/app/(app)/claw/components/ChangelogTab.tsx @@ -23,6 +23,10 @@ const DEPLOY_HINT_STYLES = { label: 'Redeploy Required', className: 'border-red-500/30 bg-red-500/15 text-red-400', }, + upgrade_required: { + label: 'Upgrade Required', + className: 'border-purple-500/30 bg-purple-500/15 text-purple-400', + }, } as const; function ChangelogRow({ entry }: { entry: ChangelogEntry }) { diff --git a/src/app/(app)/claw/components/SettingsTab.tsx b/src/app/(app)/claw/components/SettingsTab.tsx index 43df6f578..31293ce00 100644 --- a/src/app/(app)/claw/components/SettingsTab.tsx +++ b/src/app/(app)/claw/components/SettingsTab.tsx @@ -58,6 +58,94 @@ import { WorkspaceFileEditor } from './WorkspaceFileEditor'; type ClawMutations = ReturnType; +// --------------------------------------------------------------------------- +// 1Password setup guide dialog +// --------------------------------------------------------------------------- + +function OnePasswordSetupGuide() { + return ( + + + + + + + 1Password Setup + + Give your agent access to look up credentials and manage vault items. + + +
+
+

+ Warning: this gives your agent read/write access to the vault(s) you grant. Create a + dedicated vault with only the credentials your agent needs. +

+
+ +
+

1. Create a Service Account

+

+ Go to{' '} + + developer.1password.com + {' '} + and create a new service account. +

+
+ +
+

2. Scope to a dedicated vault

+

+ Grant the service account access to a dedicated "Agent" vault — not your + primary vault. Only store credentials your agent needs in this vault. +

+
+ +
+

3. Copy the token

+

+ Copy the service account token (starts with{' '} + ops_) and paste it into the field + above. +

+
+ +
+

4. Save and upgrade

+

+ After saving, use Upgrade to latest (not just Redeploy) to activate + the integration. Your agent can then use the{' '} + op CLI to look up credentials, e.g.{' '} + op item get "My Login". +

+
+ +

+ Learn more at{' '} + + 1Password Service Accounts docs + +

+
+
+
+ ); +} + // --------------------------------------------------------------------------- // AgentCard setup guide dialog // --------------------------------------------------------------------------- @@ -689,6 +777,28 @@ export function SettingsTab({ )} + {/* ── Password Managers ── */} + {toolEntries.some(e => e.id === 'onepassword') && ( +
+

Password Managers

+
+ {toolEntries + .filter(e => e.id === 'onepassword') + .map(entry => ( + } + /> + ))} +
+
+ )} + {/* ── Productivity ── */}

Productivity

diff --git a/src/app/(app)/claw/components/changelog-data.ts b/src/app/(app)/claw/components/changelog-data.ts index 5e4315c0b..d29a4f684 100644 --- a/src/app/(app)/claw/components/changelog-data.ts +++ b/src/app/(app)/claw/components/changelog-data.ts @@ -1,5 +1,5 @@ type ChangelogCategory = 'feature' | 'bugfix'; -type ChangelogDeployHint = 'redeploy_suggested' | 'redeploy_required' | null; +type ChangelogDeployHint = 'redeploy_suggested' | 'redeploy_required' | 'upgrade_required' | null; export type ChangelogEntry = { date: string; // ISO date string, e.g. "2026-02-18" @@ -10,6 +10,13 @@ export type ChangelogEntry = { // Newest entries first. Developers add new entries to the top of this array. export const CHANGELOG_ENTRIES: ChangelogEntry[] = [ + { + date: '2026-03-19', + description: + 'Added 1Password integration. Connect a service account token in Settings to give your agent access to the op CLI for looking up credentials and managing vault items.', + category: 'feature', + deployHint: 'upgrade_required', + }, { date: '2026-03-19', description: diff --git a/src/app/(app)/claw/components/secret-ui-adapter.ts b/src/app/(app)/claw/components/secret-ui-adapter.ts index 420f7aba4..482623a13 100644 --- a/src/app/(app)/claw/components/secret-ui-adapter.ts +++ b/src/app/(app)/claw/components/secret-ui-adapter.ts @@ -1,6 +1,6 @@ import type React from 'react'; import type { SecretIconKey } from '@kilocode/kiloclaw-secret-catalog'; -import { Key } from 'lucide-react'; +import { Key, Lock } from 'lucide-react'; import { TelegramIcon } from './icons/TelegramIcon'; import { DiscordIcon } from './icons/DiscordIcon'; import { SlackIcon } from './icons/SlackIcon'; @@ -14,6 +14,7 @@ const ICON_MAP: Record { @@ -27,6 +28,7 @@ const DESCRIPTION_MAP: Record = { slack: 'Connect your Slack workspace', github: 'Connect a GitHub account for code operations', agentcard: 'Give your bot virtual debit cards for spending', + onepassword: 'Look up credentials and manage vault items via the op CLI', }; export function getDescription(entryId: string): string { diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index a4cfef599..d08516075 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -10,6 +10,7 @@ import { encryptKiloClawSecret } from '@/lib/kiloclaw/encryption'; import { ALL_SECRET_FIELD_KEYS, FIELD_KEY_TO_ENTRY, + MAX_SECRET_FIELD_LENGTH, validateFieldValue, type SecretFieldKey, } from '@kilocode/kiloclaw-secret-catalog'; @@ -514,7 +515,7 @@ export const kiloclawRouter = createTRPCRouter({ .input( z.object({ secrets: z - .record(z.string(), z.string().max(500).nullable()) + .record(z.string(), z.string().max(MAX_SECRET_FIELD_LENGTH).nullable()) .refine(obj => Object.keys(obj).every(k => ALL_SECRET_FIELD_KEYS.has(k)), { message: 'Unknown secret field key', }),