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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@verboo/code",
"version": "0.15.26",
"version": "0.15.27",
"description": "Verboo Code — coding agent for the Verboo platform",
"type": "module",
"bin": {
Expand Down
8 changes: 7 additions & 1 deletion src/utils/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1266,7 +1266,13 @@ export function saveOAuthTokensIfNeeded(tokens: OAuthTokens): {
},
}

const updateStatus = secureStorage.update(storageData)
// A malformed native record cannot be read to preserve provider accounts,
// but a successful OAuth login is an explicit recovery boundary: replace
// the unreadable record with the freshly authenticated session. Other
// storage failures remain fail-closed.
const updateStatus = secureStorage.update(storageData, {
replaceCorrupt: true,
})

if (updateStatus.success) {
logEvent('tengu_oauth_tokens_saved', { storageBackend })
Expand Down
19 changes: 19 additions & 0 deletions src/utils/secureStorage/fallbackStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,23 @@ describe('fallback secure storage classification', () => {
const secondary = storage(() => ({ kind: 'ok', data }))
expect(createFallbackStorage(primary, secondary).readResult?.()).toEqual({ kind: 'ok', data })
})

test('passes the corrupt-record recovery option to the native vault', () => {
let receivedOptions: unknown
const primary: SecureStorage = {
...storage(() => ({ kind: 'error', warning: 'Secret Service returned malformed JSON.' })),
update: (_data, options) => {
receivedOptions = options
return { success: true }
},
}
const secondary = storage(() => ({ kind: 'missing' }))

const result = createFallbackStorage(primary, secondary).update(data, {
replaceCorrupt: true,
})

expect(result.success).toBe(true)
expect(receivedOptions).toEqual({ replaceCorrupt: true })
})
})
9 changes: 6 additions & 3 deletions src/utils/secureStorage/fallbackStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,14 @@ export function createFallbackStorage(
if (fallback) return { kind: 'ok', data: fallback }
return result.kind === 'error' ? result : { kind: 'missing' }
},
update(data: SecureStorageData): { success: boolean; warning?: string } {
update(
data: SecureStorageData,
options?: { preserveProviderAccounts?: boolean; lockHeld?: boolean; replaceCorrupt?: boolean },
): { success: boolean; warning?: string } {
// Capture state before update
const primaryDataBefore = primary.read()

const result = primary.update(data)
const result = primary.update(data, options)

if (result.success) {
// Delete secondary when migrating to primary for the first time
Expand All @@ -71,7 +74,7 @@ export function createFallbackStorage(
return result
}

const fallbackResult = secondary.update(data)
const fallbackResult = secondary.update(data, options)

if (fallbackResult.success) {
// Primary write failed but primary may still hold an *older* valid
Expand Down
13 changes: 11 additions & 2 deletions src/utils/secureStorage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,12 @@ export interface SecureStorage {
readResultAsync?(): Promise<SecureStorageReadResult>
update(
data: SecureStorageData,
options?: { preserveProviderAccounts?: boolean; lockHeld?: boolean },
options?: {
preserveProviderAccounts?: boolean
lockHeld?: boolean
/** Replace a record that is known to contain malformed JSON. */
replaceCorrupt?: boolean
},
): { success: boolean; warning?: string }
delete(): boolean
}
Expand Down Expand Up @@ -145,7 +150,11 @@ function preserveProviderAccountsOnSharedWrites(storage: SecureStorage): SecureS
let next = data
if (options.preserveProviderAccounts !== false) {
const current = storage.readResult?.()
if (current?.kind === 'error') {
if (
current?.kind === 'error' &&
!(options.replaceCorrupt &&
current.warning?.includes('returned malformed JSON.'))
) {
return { success: false, warning: current.warning ?? 'Secure storage read failed.' }
}
if (current?.kind === 'ok' && current.data.providerAccounts) {
Expand Down
35 changes: 32 additions & 3 deletions src/utils/secureStorage/linuxSecretStorage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { execaSync } from 'execa'
import { deflateRawSync, inflateRawSync } from 'node:zlib'
import { jsonParse, jsonStringify } from '../slowOperations.js'
import {
CREDENTIALS_SERVICE_SUFFIX,
Expand All @@ -7,6 +8,28 @@ import {
} from './macOsKeychainHelpers.js'
import type { SecureStorage, SecureStorageData, SecureStorageReadResult } from './index.js'

// KWallet's Secret Service adapter accepts writes larger than this but
// truncates the stored secret at 8192 bytes while still returning success.
// Store a compact, versioned representation so the shared credentials record
// remains safe as provider accounts accumulate.
const KWalletSecretLimitBytes = 8192
const COMPRESSED_PAYLOAD_PREFIX = 'verboo-secure-v1:'

function encodePayload(data: SecureStorageData): string {
const json = jsonStringify(data)
return `${COMPRESSED_PAYLOAD_PREFIX}${deflateRawSync(Buffer.from(json, 'utf8')).toString('base64')}`
}

function decodePayload(payload: string): SecureStorageData {
if (!payload.startsWith(COMPRESSED_PAYLOAD_PREFIX)) {
return jsonParse(payload)
}

const encoded = payload.slice(COMPRESSED_PAYLOAD_PREFIX.length)
const json = inflateRawSync(Buffer.from(encoded, 'base64')).toString('utf8')
return jsonParse(json)
}

/**
* Linux-specific secure storage implementation using the secret-tool CLI.
* secret-tool interacts with the Secret Service API (GNOME Keyring, KWallet, etc.).
Expand All @@ -27,7 +50,7 @@ export const linuxSecretStorage: SecureStorage = {
)

if (result.exitCode === 0 && result.stdout) {
return jsonParse(result.stdout)
return decodePayload(result.stdout)
}
} catch {
// fall through
Expand All @@ -47,7 +70,7 @@ export const linuxSecretStorage: SecureStorage = {
)
if (result.exitCode === 0 && result.stdout) {
try {
return { kind: 'ok', data: jsonParse(result.stdout) }
return { kind: 'ok', data: decodePayload(result.stdout) }
} catch {
return { kind: 'error', warning: 'Secret Service returned malformed JSON.' }
}
Expand All @@ -70,7 +93,13 @@ export const linuxSecretStorage: SecureStorage = {
const serviceName = getSecureStorageServiceName(
CREDENTIALS_SERVICE_SUFFIX,
)
const payload = jsonStringify(data)
const payload = encodePayload(data)
if (Buffer.byteLength(payload, 'utf8') > KWalletSecretLimitBytes) {
return {
success: false,
warning: 'Secure Service payload exceeds the Linux keyring limit.',
}
}
// secret-tool store --label=[label] service [service] account [account]
// The payload is passed via stdin
const result = execaSync(
Expand Down
44 changes: 43 additions & 1 deletion src/utils/secureStorage/platformStorage.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@

import { expect, test, mock, describe, beforeEach, afterEach } from "bun:test";
import { randomBytes } from 'node:crypto';
import * as fs from 'node:fs';
import { deflateRawSync, inflateRawSync } from 'node:zlib';
import { linuxSecretStorage } from "./linuxSecretStorage.js";
import { windowsCredentialStorage } from "./windowsCredentialStorage.js";
import { macOsKeychainStorage } from "./macOsKeychainStorage.js";
Expand Down Expand Up @@ -95,6 +97,14 @@ describe("Secure Storage Platform Implementations", () => {
expect(linuxSecretStorage.readResult?.()).toEqual({ kind: "missing" });
});

test("Linux classified reads identify malformed JSON", () => {
mockExecaSync.mockReturnValue({ exitCode: 0, stdout: "legacy-token", stderr: "" });
expect(linuxSecretStorage.readResult?.()).toEqual({
kind: "error",
warning: "Secret Service returned malformed JSON.",
});
});

test("Windows classified reads distinguish a missing DPAPI file", () => {
encryptedFile = Object.assign(new Error('Missing'), { code: 'ENOENT' });
expect(windowsCredentialStorage.readResult?.()).toEqual({ kind: "missing" });
Expand Down Expand Up @@ -394,7 +404,10 @@ describe("Secure Storage Platform Implementations", () => {
linuxSecretStorage.update(testData);

const options = execaCalls()[0][2];
expect(options.input).toContain("secret-token");
expect(options.input?.startsWith("verboo-secure-v1:")).toBe(true);
const encoded = options.input!.slice("verboo-secure-v1:".length);
const decoded = inflateRawSync(Buffer.from(encoded, 'base64')).toString('utf8');
expect(decoded).toContain("secret-token");
});

test("read parses stdout", () => {
Expand All @@ -403,6 +416,35 @@ describe("Secure Storage Platform Implementations", () => {

expect(result).toEqual(testData);
});

test("read parses the compressed payload written for KWallet", () => {
const json = JSON.stringify(testData);
const payload = `verboo-secure-v1:${deflateRawSync(Buffer.from(json)).toString('base64')}`;
mockExecaSync.mockReturnValue({ exitCode: 0, stdout: payload });

expect(linuxSecretStorage.read()).toEqual(testData);
});

test("rejects a compressed payload that exceeds KWallet's limit", () => {
const oversized = {
mcpOAuth: {
server: {
accessToken: randomBytes(20_000).toString('base64'),
expiresAt: 1,
serverName: 'server',
serverUrl: 'https://example.invalid',
},
},
};

const result = linuxSecretStorage.update(oversized);

expect(result).toEqual({
success: false,
warning: 'Secure Service payload exceeds the Linux keyring limit.',
});
expect(mockExecaSync).not.toHaveBeenCalled();
});
});

describe("Platform Selection", () => {
Expand Down
Loading