Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions kiloclaw/controller/src/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
generateHooksToken,
configureGitHub,
runOnboardOrDoctor,
updateToolsMd1PasswordSection,
buildGatewayArgs,
bootstrap,
} from './bootstrap';
Expand Down Expand Up @@ -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<typeof vi.fn>).mockReturnValue('# TOOLS\n');

const env: Record<string, string | undefined> = {
OP_SERVICE_ACCOUNT_TOKEN: 'ops_test123',
};

updateToolsMd1PasswordSection(env, harness.deps);

expect(harness.writeCalls).toHaveLength(1);
expect(harness.writeCalls[0]!.data).toContain('<!-- BEGIN:1password -->');
expect(harness.writeCalls[0]!.data).toContain('op vault list');
expect(harness.writeCalls[0]!.data).toContain('<!-- END:1password -->');
});

it('skips adding when section already present', () => {
const harness = fakeDeps();
(harness.deps.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue(
'# TOOLS\n<!-- BEGIN:1password -->\nexisting\n<!-- END:1password -->'
);

const env: Record<string, string | undefined> = {
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<typeof vi.fn>).mockReturnValue(
'# TOOLS\n<!-- BEGIN:1password -->\nold section\n<!-- END:1password -->\n'
);

const env: Record<string, string | undefined> = {};

updateToolsMd1PasswordSection(env, harness.deps);

expect(harness.writeCalls).toHaveLength(1);
expect(harness.writeCalls[0]!.data).not.toContain('<!-- BEGIN:1password -->');
});

it('no-ops when TOOLS.md does not exist', () => {
const harness = fakeDeps();
(harness.deps.existsSync as ReturnType<typeof vi.fn>).mockReturnValue(false);

const env: Record<string, string | undefined> = {
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<typeof vi.fn>).mockReturnValue('# TOOLS\n');

const env: Record<string, string | undefined> = {};

updateToolsMd1PasswordSection(env, harness.deps);

expect(harness.writeCalls).toHaveLength(0);
});
});

// ---- buildGatewayArgs ----

describe('buildGatewayArgs', () => {
Expand Down
63 changes: 62 additions & 1 deletion kiloclaw/controller/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<!-- BEGIN:1password -->';
const OP_MARKER_END = '<!-- END:1password -->';

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 <vault-name>\`
- Get a credential: \`op item get "<item-name>" --vault <vault-name>\`
- Get specific field: \`op item get "<item-name>" --fields password --vault <vault-name>\`
- 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.
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
27 changes: 27 additions & 0 deletions kiloclaw/packages/secret-catalog/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -192,6 +214,11 @@ export const FIELD_KEY_TO_ENTRY: ReadonlyMap<string, SecretCatalogEntry> = 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<string> = new Set(
SECRET_CATALOG.flatMap(entry => entry.fields.map(field => field.envVar))
Expand Down
1 change: 1 addition & 0 deletions kiloclaw/packages/secret-catalog/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions kiloclaw/packages/secret-catalog/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const SecretIconKeySchema = z.enum([
'key',
'github',
'credit-card',
'lock',
]);

/**
Expand Down
13 changes: 10 additions & 3 deletions kiloclaw/src/routes/kiloclaw.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ describe('buildConfiguredSecrets', () => {
slack: false,
github: false,
agentcard: false,
onepassword: false,
});
});

Expand All @@ -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);
});
Expand Down Expand Up @@ -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<string, unknown> },
encryptedSecrets: {
TELEGRAM_BOT_TOKEN: null as unknown as Record<string, unknown>,
},
});
expect(result.telegram).toBe(false);
});
Expand Down
4 changes: 4 additions & 0 deletions src/app/(app)/claw/components/ChangelogCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down
4 changes: 4 additions & 0 deletions src/app/(app)/claw/components/ChangelogTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down
Loading
Loading