Skip to content

Commit ccb149f

Browse files
authored
feat(kiloclaw): add 1Password integration via op CLI (#1333)
<!-- PR title format: type(scope): description — e.g., feat(auth): add SSO login --> <!-- Keep the title under 72 characters, use imperative mood, no trailing period. --> ## Summary - Add 1Password as a password manager integration in KiloClaw, following the catalog driven pattern - Users configure a Service Account Token (ops_...) in Settings; the op CLI (already installed in the Dockerfile at v2.33.0) reads OP_SERVICE_ACCOUNT_TOKEN from the environment automatically - Bootstrap writes a TOOLS.md section so the agent knows op is available and how to use it - Fix: replace hardcoded .max(500) Zod cap in patchSecrets with MAX_SECRET_FIELD_LENGTH derived from the catalog (1Password tokens are ~850 chars) - Add upgrade_required as a new changelog deploy hint type (purple badge), distinct from redeploy_required ## Verification - pnpm run typecheck — passes - pnpm run format:check — passes - pnpm run test — 2721 passed, 175 suites - vitest run bootstrap.test.ts — 44 passed (includes 5 new tests for TOOLS.md 1Password section) - Manually tested token validation against 3 real 1Password service account tokens (848-852 chars) - Verified regex matches via new RegExp() in Node to ensure the catalog pattern works at runtime <img width="523" height="151" alt="Screenshot 2026-03-20 at 2 40 59 PM" src="https://github.com/user-attachments/assets/86a2e969-2c9f-4bf2-b14e-4eac1787b82d" /> ## Visual Changes - New Password Managers section in Settings tab (between Payments and Productivity) - 1Password entry with Lock icon, collapsible accordion, and "Setup Guide" dialog with security warning - New purple Upgrade Required badge in changelog (alongside existing blue/red badges) <img width="839" height="311" alt="Screenshot 2026-03-20 at 2 44 06 PM" src="https://github.com/user-attachments/assets/c68ad38f-9618-4561-825c-23d23e95dd8d" /> ## Reviewer Notes - No Dockerfile changes — op CLI is already installed. The controller bootstrap change requires a new image build (CI triggers on controller source change) and users must Upgrade to latest (not just Redeploy) to get the TOOLS.md section - The token save + env injection works immediately on Redeploy; the agent awareness via TOOLS.md only activates after Upgrade. This is a known gap — the version/upgrade system doesn't currently surface controller-only changes as "update available" - MAX_SECRET_FIELD_LENGTH is currently 2000 (driven by AgentCard JWT maxLength). This raises the blanket Zod cap from 500 → 2000, which is well within CF Workers/Next.js payload limits - Service Accounts are available on all 1Password plans (Individual, Family, Teams, Business) with different rate limits. No plan restriction needed - The validationPattern uses \\- in the character class — harmless, matches the style of other catalog patterns (e.g., Discord)
2 parents 124a867 + fdf6a5d commit ccb149f

13 files changed

Lines changed: 311 additions & 9 deletions

File tree

kiloclaw/controller/src/bootstrap.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
generateHooksToken,
88
configureGitHub,
99
runOnboardOrDoctor,
10+
updateToolsMd1PasswordSection,
1011
buildGatewayArgs,
1112
bootstrap,
1213
} from './bootstrap';
@@ -567,6 +568,79 @@ describe('runOnboardOrDoctor', () => {
567568
});
568569
});
569570

571+
// ---- updateToolsMd1PasswordSection ----
572+
573+
describe('updateToolsMd1PasswordSection', () => {
574+
it('adds 1Password section when OP_SERVICE_ACCOUNT_TOKEN is set', () => {
575+
const harness = fakeDeps();
576+
(harness.deps.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue('# TOOLS\n');
577+
578+
const env: Record<string, string | undefined> = {
579+
OP_SERVICE_ACCOUNT_TOKEN: 'ops_test123',
580+
};
581+
582+
updateToolsMd1PasswordSection(env, harness.deps);
583+
584+
expect(harness.writeCalls).toHaveLength(1);
585+
expect(harness.writeCalls[0]!.data).toContain('<!-- BEGIN:1password -->');
586+
expect(harness.writeCalls[0]!.data).toContain('op vault list');
587+
expect(harness.writeCalls[0]!.data).toContain('<!-- END:1password -->');
588+
});
589+
590+
it('skips adding when section already present', () => {
591+
const harness = fakeDeps();
592+
(harness.deps.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue(
593+
'# TOOLS\n<!-- BEGIN:1password -->\nexisting\n<!-- END:1password -->'
594+
);
595+
596+
const env: Record<string, string | undefined> = {
597+
OP_SERVICE_ACCOUNT_TOKEN: 'ops_test123',
598+
};
599+
600+
updateToolsMd1PasswordSection(env, harness.deps);
601+
602+
expect(harness.writeCalls).toHaveLength(0);
603+
});
604+
605+
it('removes stale section when token is absent', () => {
606+
const harness = fakeDeps();
607+
(harness.deps.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue(
608+
'# TOOLS\n<!-- BEGIN:1password -->\nold section\n<!-- END:1password -->\n'
609+
);
610+
611+
const env: Record<string, string | undefined> = {};
612+
613+
updateToolsMd1PasswordSection(env, harness.deps);
614+
615+
expect(harness.writeCalls).toHaveLength(1);
616+
expect(harness.writeCalls[0]!.data).not.toContain('<!-- BEGIN:1password -->');
617+
});
618+
619+
it('no-ops when TOOLS.md does not exist', () => {
620+
const harness = fakeDeps();
621+
(harness.deps.existsSync as ReturnType<typeof vi.fn>).mockReturnValue(false);
622+
623+
const env: Record<string, string | undefined> = {
624+
OP_SERVICE_ACCOUNT_TOKEN: 'ops_test123',
625+
};
626+
627+
updateToolsMd1PasswordSection(env, harness.deps);
628+
629+
expect(harness.writeCalls).toHaveLength(0);
630+
});
631+
632+
it('no-ops when token absent and no stale section exists', () => {
633+
const harness = fakeDeps();
634+
(harness.deps.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue('# TOOLS\n');
635+
636+
const env: Record<string, string | undefined> = {};
637+
638+
updateToolsMd1PasswordSection(env, harness.deps);
639+
640+
expect(harness.writeCalls).toHaveLength(0);
641+
});
642+
});
643+
570644
// ---- buildGatewayArgs ----
571645

572646
describe('buildGatewayArgs', () => {

kiloclaw/controller/src/bootstrap.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,67 @@ export function updateToolsMdGoogleSection(env: EnvLike, deps: BootstrapDeps): v
442442
}
443443
}
444444

445-
// ---- Step 8: Gateway args ----
445+
// ---- Step 8: TOOLS.md 1Password section ----
446+
447+
const OP_MARKER_BEGIN = '<!-- BEGIN:1password -->';
448+
const OP_MARKER_END = '<!-- END:1password -->';
449+
450+
const OP_TOOLS_SECTION = `
451+
${OP_MARKER_BEGIN}
452+
## 1Password
453+
454+
The \`op\` CLI is configured with a 1Password service account. Use it to look up credentials, generate passwords, and manage vault items.
455+
456+
- List vaults: \`op vault list\`
457+
- Search items: \`op item list --vault <vault-name>\`
458+
- Get a credential: \`op item get "<item-name>" --vault <vault-name>\`
459+
- Get specific field: \`op item get "<item-name>" --fields password --vault <vault-name>\`
460+
- Generate password: \`op item create --category login --title "New Login" --generate-password\`
461+
- Run \`op --help\` for all available commands.
462+
463+
**Security note:** Only access credentials the user has explicitly requested. Do not list or expose vault contents unnecessarily.
464+
${OP_MARKER_END}`;
465+
466+
/**
467+
* Manage the 1Password section in TOOLS.md.
468+
*
469+
* When OP_SERVICE_ACCOUNT_TOKEN is present, append a bounded section so the
470+
* agent knows the op CLI is available. When absent, remove any stale section.
471+
* Idempotent: skips if the marker is already present.
472+
*/
473+
export function updateToolsMd1PasswordSection(env: EnvLike, deps: BootstrapDeps): void {
474+
if (!deps.existsSync(TOOLS_MD_DEST)) return;
475+
476+
const content = deps.readFileSync(TOOLS_MD_DEST, 'utf8');
477+
478+
if (env.OP_SERVICE_ACCOUNT_TOKEN) {
479+
// 1Password configured — add section if not already present
480+
if (!content.includes(OP_MARKER_BEGIN)) {
481+
deps.writeFileSync(TOOLS_MD_DEST, content + OP_TOOLS_SECTION);
482+
console.log('TOOLS.md: added 1Password section');
483+
} else {
484+
console.log('TOOLS.md: 1Password section already present');
485+
}
486+
} else {
487+
// 1Password not configured — remove stale section if present
488+
if (content.includes(OP_MARKER_BEGIN)) {
489+
const beginIdx = content.indexOf(OP_MARKER_BEGIN);
490+
const endIdx = content.indexOf(OP_MARKER_END);
491+
if (beginIdx !== -1 && endIdx !== -1) {
492+
const before = content.slice(0, beginIdx).replace(/\n+$/, '\n');
493+
const after = content.slice(endIdx + OP_MARKER_END.length).replace(/^\n+/, '');
494+
deps.writeFileSync(TOOLS_MD_DEST, before + after);
495+
console.log('TOOLS.md: removed stale 1Password section');
496+
} else {
497+
console.warn(
498+
'TOOLS.md: 1Password BEGIN marker found but END marker missing, skipping removal'
499+
);
500+
}
501+
}
502+
}
503+
}
504+
505+
// ---- Step 9: Gateway args ----
446506

447507
/**
448508
* Build the gateway CLI arguments array.
@@ -497,6 +557,7 @@ export async function bootstrap(
497557
await yieldToEventLoop();
498558

499559
updateToolsMdGoogleSection(env, deps);
560+
updateToolsMd1PasswordSection(env, deps);
500561

501562
// Write mcporter config for MCP servers (AgentCard, etc.)
502563
writeMcporterConfig(env);

kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ describe('Secret Catalog', () => {
4444
'key',
4545
'github',
4646
'credit-card',
47+
'lock',
4748
]);
4849
for (const entry of SECRET_CATALOG) {
4950
expect(validIcons.has(entry.icon)).toBe(true);
@@ -193,9 +194,10 @@ describe('Secret Catalog', () => {
193194

194195
it('returns all tool entries sorted by order', () => {
195196
const tools = getEntriesByCategory('tool');
196-
expect(tools.length).toBe(2);
197+
expect(tools.length).toBe(3);
197198
expect(tools[0].id).toBe('github');
198199
expect(tools[1].id).toBe('agentcard');
200+
expect(tools[2].id).toBe('onepassword');
199201
});
200202

201203
it('returns empty array for categories with no entries', () => {
@@ -220,7 +222,8 @@ describe('Secret Catalog', () => {
220222
expect(keys).toContain('githubUsername');
221223
expect(keys).toContain('githubEmail');
222224
expect(keys).toContain('agentcardApiKey');
223-
expect(keys.size).toBe(4);
225+
expect(keys).toContain('onepasswordServiceAccountToken');
226+
expect(keys.size).toBe(5);
224227
});
225228

226229
it('returns empty set for categories with no entries', () => {

kiloclaw/packages/secret-catalog/src/catalog.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,28 @@ const SECRET_CATALOG_RAW = [
154154
helpText: 'Virtual debit cards for autonomous agent spending. See setup guide for details.',
155155
helpUrl: 'https://agentcard.sh',
156156
},
157+
{
158+
id: 'onepassword',
159+
label: '1Password',
160+
category: 'tool',
161+
icon: 'lock',
162+
order: 3,
163+
fields: [
164+
{
165+
key: 'onepasswordServiceAccountToken',
166+
label: 'Service Account Token',
167+
placeholder: 'ops_...',
168+
placeholderConfigured: 'Enter new token to replace',
169+
envVar: 'OP_SERVICE_ACCOUNT_TOKEN',
170+
validationPattern: '^ops_[A-Za-z0-9_\\-]{50,1500}$',
171+
validationMessage:
172+
'1Password service account tokens start with ops_ followed by a long base64-encoded string.',
173+
maxLength: 2000,
174+
},
175+
],
176+
helpText: 'Create a service account at 1password.com with access to a dedicated vault.',
177+
helpUrl: 'https://developer.1password.com/docs/service-accounts/get-started/',
178+
},
157179
] as const satisfies readonly SecretCatalogEntry[];
158180

159181
// Runtime validation — fails fast at module load if catalog data is malformed
@@ -192,6 +214,11 @@ export const FIELD_KEY_TO_ENTRY: ReadonlyMap<string, SecretCatalogEntry> = new M
192214
SECRET_CATALOG.flatMap(entry => entry.fields.map(field => [field.key, entry]))
193215
);
194216

217+
/** Largest maxLength across all catalog fields (for blanket Zod schema caps) */
218+
export const MAX_SECRET_FIELD_LENGTH: number = Math.max(
219+
...SECRET_CATALOG.flatMap(entry => entry.fields.map(field => field.maxLength))
220+
);
221+
195222
/** Set of all env var names from catalog entries (for SENSITIVE_KEYS classification) */
196223
export const ALL_SECRET_ENV_VARS: ReadonlySet<string> = new Set(
197224
SECRET_CATALOG.flatMap(entry => entry.fields.map(field => field.envVar))

kiloclaw/packages/secret-catalog/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export {
2727
ENV_VAR_TO_FIELD_KEY,
2828
FIELD_KEY_TO_ENTRY,
2929
ALL_SECRET_ENV_VARS,
30+
MAX_SECRET_FIELD_LENGTH,
3031
INTERNAL_SENSITIVE_ENV_VARS,
3132
getEntriesByCategory,
3233
getFieldKeysByCategory,

kiloclaw/packages/secret-catalog/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const SecretIconKeySchema = z.enum([
1111
'key',
1212
'github',
1313
'credit-card',
14+
'lock',
1415
]);
1516

1617
/**

kiloclaw/src/routes/kiloclaw.test.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ describe('buildConfiguredSecrets', () => {
1717
slack: false,
1818
github: false,
1919
agentcard: false,
20+
onepassword: false,
2021
});
2122
});
2223

@@ -36,7 +37,10 @@ describe('buildConfiguredSecrets', () => {
3637
expect(partial.slack).toBe(false);
3738

3839
const full = buildConfiguredSecrets({
39-
encryptedSecrets: { SLACK_BOT_TOKEN: envelope, SLACK_APP_TOKEN: envelope },
40+
encryptedSecrets: {
41+
SLACK_BOT_TOKEN: envelope,
42+
SLACK_APP_TOKEN: envelope,
43+
},
4044
});
4145
expect(full.slack).toBe(true);
4246
});
@@ -107,12 +111,15 @@ describe('buildConfiguredSecrets', () => {
107111
expect(keys).toContain('telegram');
108112
expect(keys).toContain('discord');
109113
expect(keys).toContain('slack');
110-
expect(keys).toHaveLength(5);
114+
expect(keys).toContain('onepassword');
115+
expect(keys).toHaveLength(6);
111116
});
112117

113118
it('treats null values as not configured', () => {
114119
const result = buildConfiguredSecrets({
115-
encryptedSecrets: { TELEGRAM_BOT_TOKEN: null as unknown as Record<string, unknown> },
120+
encryptedSecrets: {
121+
TELEGRAM_BOT_TOKEN: null as unknown as Record<string, unknown>,
122+
},
116123
});
117124
expect(result.telegram).toBe(false);
118125
});

src/app/(app)/claw/components/ChangelogCard.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ const DEPLOY_HINT_STYLES = {
2424
label: 'Redeploy Required',
2525
className: 'border-red-500/30 bg-red-500/15 text-red-400',
2626
},
27+
upgrade_required: {
28+
label: 'Upgrade Required',
29+
className: 'border-purple-500/30 bg-purple-500/15 text-purple-400',
30+
},
2731
} as const;
2832

2933
function ChangelogRow({ entry }: { entry: ChangelogEntry }) {

src/app/(app)/claw/components/ChangelogTab.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ const DEPLOY_HINT_STYLES = {
2323
label: 'Redeploy Required',
2424
className: 'border-red-500/30 bg-red-500/15 text-red-400',
2525
},
26+
upgrade_required: {
27+
label: 'Upgrade Required',
28+
className: 'border-purple-500/30 bg-purple-500/15 text-purple-400',
29+
},
2630
} as const;
2731

2832
function ChangelogRow({ entry }: { entry: ChangelogEntry }) {

0 commit comments

Comments
 (0)