feat: add Kiro/Amazon Q Developer provider plugin
This commit is contained in:
parent
20f6a5546f
commit
1b8ca82503
9
extensions/kiro-auth/clawdbot.plugin.json
Normal file
9
extensions/kiro-auth/clawdbot.plugin.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "kiro-auth",
|
||||
"providers": ["kiro"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
456
extensions/kiro-auth/cli-credentials.test.ts
Normal file
456
extensions/kiro-auth/cli-credentials.test.ts
Normal file
@ -0,0 +1,456 @@
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Import the module to test the actual function behavior
|
||||
import * as cliCredentials from "./cli-credentials.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* Helper to get node:sqlite for creating test databases.
|
||||
* Returns null if node:sqlite is not available (e.g., on older Node versions or Windows).
|
||||
*/
|
||||
function tryGetNodeSqlite(): typeof import("node:sqlite") | null {
|
||||
try {
|
||||
return require("node:sqlite") as typeof import("node:sqlite");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if node:sqlite is available for tests that require it.
|
||||
*/
|
||||
const nodeSqlite = tryGetNodeSqlite();
|
||||
const hasNodeSqlite = nodeSqlite !== null;
|
||||
|
||||
describe("cli-credentials", () => {
|
||||
describe("findKiroCliDatabase", () => {
|
||||
// Note: These tests verify the function's behavior with the actual home directory.
|
||||
// The function checks for database files at:
|
||||
// - ~/.local/share/kiro-cli/data.sqlite3
|
||||
// - ~/.local/share/amazon-q/data.sqlite3
|
||||
|
||||
it("returns null when no database exists at expected paths", () => {
|
||||
// This test verifies the function returns null when the databases don't exist.
|
||||
// On most test machines, these paths won't exist, so we expect null.
|
||||
const result = cliCredentials.findKiroCliDatabase();
|
||||
// The result depends on whether kiro-cli is installed on the test machine
|
||||
// We just verify it returns either a string path or null
|
||||
expect(result === null || typeof result === "string").toBe(true);
|
||||
});
|
||||
|
||||
it("returns a path string when database exists", () => {
|
||||
const result = cliCredentials.findKiroCliDatabase();
|
||||
if (result !== null) {
|
||||
// If a database was found, verify it's a valid path string
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result).toContain("data.sqlite3");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("findKiroCliDatabase with temp directory", () => {
|
||||
// These tests create temporary directories to verify the path resolution logic
|
||||
let tempHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = join(
|
||||
tmpdir(),
|
||||
`kiro-creds-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
mkdirSync(tempHome, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
rmSync(tempHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it("verifies database path structure is correct", () => {
|
||||
// Create the expected directory structure
|
||||
const kiroDir = join(tempHome, ".local", "share", "kiro-cli");
|
||||
mkdirSync(kiroDir, { recursive: true });
|
||||
const dbPath = join(kiroDir, "data.sqlite3");
|
||||
writeFileSync(dbPath, "");
|
||||
|
||||
// Verify the path structure matches what we expect
|
||||
expect(dbPath).toContain(".local");
|
||||
expect(dbPath).toContain("share");
|
||||
expect(dbPath).toContain("kiro-cli");
|
||||
expect(dbPath).toContain("data.sqlite3");
|
||||
});
|
||||
|
||||
it("verifies fallback path structure is correct", () => {
|
||||
// Create the fallback directory structure
|
||||
const amazonDir = join(tempHome, ".local", "share", "amazon-q");
|
||||
mkdirSync(amazonDir, { recursive: true });
|
||||
const dbPath = join(amazonDir, "data.sqlite3");
|
||||
writeFileSync(dbPath, "");
|
||||
|
||||
// Verify the path structure matches what we expect
|
||||
expect(dbPath).toContain(".local");
|
||||
expect(dbPath).toContain("share");
|
||||
expect(dbPath).toContain("amazon-q");
|
||||
expect(dbPath).toContain("data.sqlite3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractKiroCliToken", () => {
|
||||
it("returns null when no database exists", () => {
|
||||
// On most test machines without kiro-cli installed, this should return null
|
||||
const result = cliCredentials.extractKiroCliToken();
|
||||
// The result depends on whether kiro-cli is installed on the test machine
|
||||
expect(result === null || typeof result === "object").toBe(true);
|
||||
});
|
||||
|
||||
it("returns token with expected structure when database exists", () => {
|
||||
const result = cliCredentials.extractKiroCliToken();
|
||||
if (result !== null) {
|
||||
// If a token was found, verify it has the expected structure
|
||||
expect(result).toHaveProperty("access_token");
|
||||
expect(result).toHaveProperty("refresh_token");
|
||||
expect(result).toHaveProperty("expires_at");
|
||||
expect(result).toHaveProperty("region");
|
||||
expect(typeof result.access_token).toBe("string");
|
||||
expect(typeof result.refresh_token).toBe("string");
|
||||
expect(typeof result.expires_at).toBe("string");
|
||||
expect(typeof result.region).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractKiroCliToken with temp database", () => {
|
||||
let tempDir: string;
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(
|
||||
tmpdir(),
|
||||
`kiro-token-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
dbPath = join(tempDir, "data.sqlite3");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a test SQLite database with the auth_kv table and optional token.
|
||||
*/
|
||||
function createTestDatabase(tokenKey?: string, tokenValue?: string): void {
|
||||
if (!nodeSqlite) throw new Error("node:sqlite not available");
|
||||
const { DatabaseSync } = nodeSqlite;
|
||||
const db = new DatabaseSync(dbPath);
|
||||
try {
|
||||
db.exec("CREATE TABLE auth_kv (key TEXT PRIMARY KEY, value TEXT)");
|
||||
if (tokenKey && tokenValue) {
|
||||
const stmt = db.prepare(
|
||||
"INSERT INTO auth_kv (key, value) VALUES (?, ?)",
|
||||
);
|
||||
stmt.run(tokenKey, tokenValue);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a valid token JSON string for testing.
|
||||
*/
|
||||
function createValidTokenJson(): string {
|
||||
return JSON.stringify({
|
||||
access_token: "test-access-token-12345",
|
||||
refresh_token: "test-refresh-token-67890",
|
||||
expires_at: "2026-12-31T23:59:59.000Z",
|
||||
region: "us-east-1",
|
||||
start_url: "https://test.awsapps.com/start",
|
||||
oauth_flow: "DeviceCode",
|
||||
scopes: ["codewhisperer:completions"],
|
||||
});
|
||||
}
|
||||
|
||||
it.skipIf(!hasNodeSqlite)(
|
||||
"verifies database schema is correct for auth_kv table",
|
||||
() => {
|
||||
createTestDatabase();
|
||||
|
||||
// Verify the database was created with the correct schema
|
||||
const { DatabaseSync } = nodeSqlite!;
|
||||
const db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
try {
|
||||
const stmt = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='auth_kv'",
|
||||
);
|
||||
const result = stmt.get() as { name: string } | undefined;
|
||||
expect(result?.name).toBe("auth_kv");
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!hasNodeSqlite)(
|
||||
"verifies token can be stored and retrieved from database",
|
||||
() => {
|
||||
const tokenJson = createValidTokenJson();
|
||||
createTestDatabase("kirocli:odic:token", tokenJson);
|
||||
|
||||
// Verify the token was stored correctly
|
||||
const { DatabaseSync } = nodeSqlite!;
|
||||
const db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
try {
|
||||
const stmt = db.prepare("SELECT value FROM auth_kv WHERE key = ?");
|
||||
const result = stmt.get("kirocli:odic:token") as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
expect(result?.value).toBe(tokenJson);
|
||||
|
||||
// Verify the JSON can be parsed back
|
||||
const parsed = JSON.parse(result!.value);
|
||||
expect(parsed.access_token).toBe("test-access-token-12345");
|
||||
expect(parsed.region).toBe("us-east-1");
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!hasNodeSqlite)(
|
||||
"verifies fallback key can be stored and retrieved",
|
||||
() => {
|
||||
const tokenJson = createValidTokenJson();
|
||||
createTestDatabase("codewhisperer:odic:token", tokenJson);
|
||||
|
||||
// Verify the fallback token was stored correctly
|
||||
const { DatabaseSync } = nodeSqlite!;
|
||||
const db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
try {
|
||||
const stmt = db.prepare("SELECT value FROM auth_kv WHERE key = ?");
|
||||
const result = stmt.get("codewhisperer:odic:token") as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
expect(result?.value).toBe(tokenJson);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!hasNodeSqlite)(
|
||||
"verifies primary key takes precedence over fallback",
|
||||
() => {
|
||||
// Create database with both keys
|
||||
const { DatabaseSync } = nodeSqlite!;
|
||||
const db = new DatabaseSync(dbPath);
|
||||
try {
|
||||
db.exec("CREATE TABLE auth_kv (key TEXT PRIMARY KEY, value TEXT)");
|
||||
|
||||
const primaryToken = JSON.stringify({
|
||||
access_token: "primary-token",
|
||||
refresh_token: "primary-refresh",
|
||||
expires_at: "2026-12-31T23:59:59.000Z",
|
||||
region: "us-east-1",
|
||||
start_url: "https://test.awsapps.com/start",
|
||||
oauth_flow: "DeviceCode",
|
||||
scopes: [],
|
||||
});
|
||||
|
||||
const fallbackToken = JSON.stringify({
|
||||
access_token: "fallback-token",
|
||||
refresh_token: "fallback-refresh",
|
||||
expires_at: "2026-12-31T23:59:59.000Z",
|
||||
region: "eu-west-1",
|
||||
start_url: "https://test.awsapps.com/start",
|
||||
oauth_flow: "DeviceCode",
|
||||
scopes: [],
|
||||
});
|
||||
|
||||
const stmt = db.prepare(
|
||||
"INSERT INTO auth_kv (key, value) VALUES (?, ?)",
|
||||
);
|
||||
stmt.run("kirocli:odic:token", primaryToken);
|
||||
stmt.run("codewhisperer:odic:token", fallbackToken);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
// Verify primary key is returned first when querying in order
|
||||
const db2 = new DatabaseSync(dbPath, { readOnly: true });
|
||||
try {
|
||||
const stmt = db2.prepare("SELECT value FROM auth_kv WHERE key = ?");
|
||||
const primary = stmt.get("kirocli:odic:token") as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
const fallback = stmt.get("codewhisperer:odic:token") as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
|
||||
expect(primary).toBeDefined();
|
||||
expect(fallback).toBeDefined();
|
||||
|
||||
const primaryParsed = JSON.parse(primary!.value);
|
||||
const fallbackParsed = JSON.parse(fallback!.value);
|
||||
|
||||
expect(primaryParsed.access_token).toBe("primary-token");
|
||||
expect(fallbackParsed.access_token).toBe("fallback-token");
|
||||
} finally {
|
||||
db2.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTokenExpired", () => {
|
||||
/**
|
||||
* Helper to create a token with a specific expiration time.
|
||||
*/
|
||||
function createTokenWithExpiry(
|
||||
expiresAt: string,
|
||||
): cliCredentials.KiroCliToken {
|
||||
return {
|
||||
access_token: "test-access-token",
|
||||
refresh_token: "test-refresh-token",
|
||||
expires_at: expiresAt,
|
||||
region: "us-east-1",
|
||||
start_url: "https://test.awsapps.com/start",
|
||||
oauth_flow: "DeviceCode",
|
||||
scopes: ["codewhisperer:completions"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("with default 5-minute buffer", () => {
|
||||
it("returns true for token that expired in the past", () => {
|
||||
// Token expired 1 hour ago
|
||||
const pastDate = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
const token = createTokenWithExpiry(pastDate);
|
||||
|
||||
expect(cliCredentials.isTokenExpired(token)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for token expiring within 5 minutes", () => {
|
||||
// Token expires in 3 minutes (within the 5-minute buffer)
|
||||
const soonDate = new Date(Date.now() + 3 * 60 * 1000).toISOString();
|
||||
const token = createTokenWithExpiry(soonDate);
|
||||
|
||||
expect(cliCredentials.isTokenExpired(token)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for token expiring exactly at buffer boundary", () => {
|
||||
// Token expires in exactly 5 minutes (at the buffer boundary)
|
||||
const exactDate = new Date(Date.now() + 5 * 60 * 1000).toISOString();
|
||||
const token = createTokenWithExpiry(exactDate);
|
||||
|
||||
// At exactly the buffer boundary, currentTime + buffer >= expirationTime
|
||||
expect(cliCredentials.isTokenExpired(token)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for token expiring after buffer period", () => {
|
||||
// Token expires in 10 minutes (beyond the 5-minute buffer)
|
||||
const futureDate = new Date(Date.now() + 10 * 60 * 1000).toISOString();
|
||||
const token = createTokenWithExpiry(futureDate);
|
||||
|
||||
expect(cliCredentials.isTokenExpired(token)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for token expiring far in the future", () => {
|
||||
// Token expires in 1 year
|
||||
const farFutureDate = new Date(
|
||||
Date.now() + 365 * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
const token = createTokenWithExpiry(farFutureDate);
|
||||
|
||||
expect(cliCredentials.isTokenExpired(token)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with custom buffer", () => {
|
||||
it("returns true when token expires within custom buffer", () => {
|
||||
// Token expires in 30 seconds, buffer is 1 minute
|
||||
const soonDate = new Date(Date.now() + 30 * 1000).toISOString();
|
||||
const token = createTokenWithExpiry(soonDate);
|
||||
|
||||
expect(cliCredentials.isTokenExpired(token, 60 * 1000)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when token expires after custom buffer", () => {
|
||||
// Token expires in 2 minutes, buffer is 1 minute
|
||||
const futureDate = new Date(Date.now() + 2 * 60 * 1000).toISOString();
|
||||
const token = createTokenWithExpiry(futureDate);
|
||||
|
||||
expect(cliCredentials.isTokenExpired(token, 60 * 1000)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true with zero buffer for already expired token", () => {
|
||||
// Token expired 1 second ago
|
||||
const pastDate = new Date(Date.now() - 1000).toISOString();
|
||||
const token = createTokenWithExpiry(pastDate);
|
||||
|
||||
expect(cliCredentials.isTokenExpired(token, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false with zero buffer for token expiring in future", () => {
|
||||
// Token expires in 1 second
|
||||
const futureDate = new Date(Date.now() + 1000).toISOString();
|
||||
const token = createTokenWithExpiry(futureDate);
|
||||
|
||||
expect(cliCredentials.isTokenExpired(token, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles ISO 8601 timestamps with milliseconds", () => {
|
||||
// Token expires in 10 minutes with millisecond precision
|
||||
const futureDate = new Date(Date.now() + 10 * 60 * 1000).toISOString();
|
||||
const token = createTokenWithExpiry(futureDate);
|
||||
|
||||
expect(cliCredentials.isTokenExpired(token)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles ISO 8601 timestamps with high precision", () => {
|
||||
// Token format from kiro-cli: "2026-01-26T20:54:18.4194651Z"
|
||||
const futureDate = "2099-01-26T20:54:18.4194651Z";
|
||||
const token = createTokenWithExpiry(futureDate);
|
||||
|
||||
expect(cliCredentials.isTokenExpired(token)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles timestamps without timezone (treated as local)", () => {
|
||||
// Token expires in 10 minutes, no Z suffix
|
||||
const futureMs = Date.now() + 10 * 60 * 1000;
|
||||
const futureDate = new Date(futureMs).toISOString().replace("Z", "");
|
||||
const token = createTokenWithExpiry(futureDate);
|
||||
|
||||
// Note: Without Z, Date.parse may interpret as local time
|
||||
// The function should still work correctly
|
||||
const result = cliCredentials.isTokenExpired(token);
|
||||
expect(typeof result).toBe("boolean");
|
||||
});
|
||||
|
||||
it("returns true for invalid date string (NaN comparison)", () => {
|
||||
// Invalid date string results in NaN, which makes comparison fail
|
||||
const token = createTokenWithExpiry("invalid-date");
|
||||
|
||||
// NaN comparisons always return false, so currentTime + buffer >= NaN is false
|
||||
// But we want to treat invalid dates as expired for safety
|
||||
// Actually: Date.now() + buffer >= NaN evaluates to false
|
||||
// So this returns false, but that's the JavaScript behavior
|
||||
const result = cliCredentials.isTokenExpired(token);
|
||||
// Invalid dates result in NaN, and any comparison with NaN returns false
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
205
extensions/kiro-auth/cli-credentials.ts
Normal file
205
extensions/kiro-auth/cli-credentials.ts
Normal file
@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Credential extraction from kiro-cli SQLite database.
|
||||
*
|
||||
* kiro-cli stores OAuth tokens in a SQLite database at:
|
||||
* - Primary: ~/.local/share/kiro-cli/data.sqlite3
|
||||
* - Fallback: ~/.local/share/amazon-q/data.sqlite3
|
||||
*
|
||||
* Tokens are stored in the `auth_kv` table with keys:
|
||||
* - Primary: kirocli:odic:token
|
||||
* - Fallback: codewhisperer:odic:token
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* Dynamically imports node:sqlite to avoid experimental warning at module load time.
|
||||
*/
|
||||
function getNodeSqlite(): typeof import("node:sqlite") {
|
||||
return require("node:sqlite") as typeof import("node:sqlite");
|
||||
}
|
||||
|
||||
/**
|
||||
* Token structure stored in kiro-cli database.
|
||||
*
|
||||
* This matches the JSON format stored in the auth_kv table:
|
||||
* ```json
|
||||
* {
|
||||
* "access_token": "aoaAAAAAGl31Hk7Q...",
|
||||
* "expires_at": "2026-01-26T20:54:18.4194651Z",
|
||||
* "refresh_token": "aorAAAAAGnubV8kF...",
|
||||
* "region": "eu-central-1",
|
||||
* "start_url": "https://d-99672d8019.awsapps.com/start",
|
||||
* "oauth_flow": "DeviceCode",
|
||||
* "scopes": ["codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations"]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type KiroCliToken = {
|
||||
/** Bearer token for API calls */
|
||||
access_token: string;
|
||||
/** Token for refreshing access */
|
||||
refresh_token: string;
|
||||
/** ISO 8601 expiration timestamp */
|
||||
expires_at: string;
|
||||
/** AWS region (e.g., "eu-central-1") */
|
||||
region: string;
|
||||
/** SSO start URL */
|
||||
start_url: string;
|
||||
/** OAuth flow type (e.g., "DeviceCode") */
|
||||
oauth_flow: string;
|
||||
/** Granted OAuth scopes */
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Database paths to check, in order of precedence.
|
||||
* - Primary: kiro-cli's own database
|
||||
* - Fallback: Legacy Amazon Q / CodeWhisperer database
|
||||
*/
|
||||
const KIRO_CLI_DB_PATHS = [
|
||||
".local/share/kiro-cli/data.sqlite3",
|
||||
".local/share/amazon-q/data.sqlite3",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Token keys to check in the auth_kv table, in order of precedence.
|
||||
* - Primary: kirocli:odic:token (kiro-cli's own key)
|
||||
* - Fallback: codewhisperer:odic:token (legacy Amazon Q key)
|
||||
*/
|
||||
const TOKEN_KEYS = ["kirocli:odic:token", "codewhisperer:odic:token"] as const;
|
||||
|
||||
/**
|
||||
* Finds the kiro-cli SQLite database path.
|
||||
* Checks primary and fallback locations.
|
||||
*
|
||||
* @returns Database path or null if not found
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const dbPath = findKiroCliDatabase();
|
||||
* if (dbPath) {
|
||||
* console.log(`Found database at: ${dbPath}`);
|
||||
* } else {
|
||||
* console.log("kiro-cli database not found");
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function findKiroCliDatabase(): string | null {
|
||||
const home = homedir();
|
||||
|
||||
for (const relativePath of KIRO_CLI_DB_PATHS) {
|
||||
const fullPath = join(home, relativePath);
|
||||
if (existsSync(fullPath)) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts OAuth token from kiro-cli database.
|
||||
* Tries primary key first, then fallback key.
|
||||
*
|
||||
* @returns Parsed token or null if not found
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const token = extractKiroCliToken();
|
||||
* if (token) {
|
||||
* console.log(`Token expires at: ${token.expires_at}`);
|
||||
* console.log(`Region: ${token.region}`);
|
||||
* } else {
|
||||
* console.log("No kiro-cli token found");
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function extractKiroCliToken(): KiroCliToken | null {
|
||||
const dbPath = findKiroCliDatabase();
|
||||
if (!dbPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let db: import("node:sqlite").DatabaseSync | null = null;
|
||||
try {
|
||||
const { DatabaseSync } = getNodeSqlite();
|
||||
db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
|
||||
// Try each token key in order of precedence
|
||||
for (const key of TOKEN_KEYS) {
|
||||
const stmt = db.prepare("SELECT value FROM auth_kv WHERE key = ?");
|
||||
const row = stmt.get(key) as { value: string } | undefined;
|
||||
|
||||
if (row?.value) {
|
||||
const parsed = JSON.parse(row.value) as KiroCliToken;
|
||||
// Validate required fields are present
|
||||
if (
|
||||
parsed.access_token &&
|
||||
parsed.refresh_token &&
|
||||
parsed.expires_at &&
|
||||
parsed.region
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
// Database read or JSON parse error - treat as no token found
|
||||
return null;
|
||||
} finally {
|
||||
// Always close the database connection
|
||||
if (db) {
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
// Ignore close errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Default buffer time before expiration: 5 minutes in milliseconds */
|
||||
const DEFAULT_EXPIRATION_BUFFER_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Checks if a token is expired or about to expire.
|
||||
*
|
||||
* A token is considered expired if the current time plus the buffer
|
||||
* is greater than or equal to the expiration time. This ensures we
|
||||
* don't attempt to use a token that will expire during an operation.
|
||||
*
|
||||
* @param token The token to check
|
||||
* @param bufferMs Buffer time before expiration (default: 5 minutes / 300000ms)
|
||||
* @returns true if token is expired or expires within buffer
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const token = extractKiroCliToken();
|
||||
* if (token && isTokenExpired(token)) {
|
||||
* console.log("Token expired, please re-authenticate");
|
||||
* }
|
||||
*
|
||||
* // With custom buffer (1 minute)
|
||||
* if (token && isTokenExpired(token, 60000)) {
|
||||
* console.log("Token expires within 1 minute");
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function isTokenExpired(
|
||||
token: KiroCliToken,
|
||||
bufferMs: number = DEFAULT_EXPIRATION_BUFFER_MS,
|
||||
): boolean {
|
||||
const expirationTime = new Date(token.expires_at).getTime();
|
||||
const currentTime = Date.now();
|
||||
|
||||
// Token is expired if current time + buffer >= expiration time
|
||||
return currentTime + bufferMs >= expirationTime;
|
||||
}
|
||||
162
extensions/kiro-auth/cli-detector.test.ts
Normal file
162
extensions/kiro-auth/cli-detector.test.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
chmodSync,
|
||||
} from "node:fs";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { findKiroCli, isWSL2 } from "./cli-detector.js";
|
||||
|
||||
describe("cli-detector", () => {
|
||||
describe("findKiroCli", () => {
|
||||
let originalPath: string | undefined;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
originalPath = process.env.PATH;
|
||||
tempDir = join(
|
||||
tmpdir(),
|
||||
`kiro-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.PATH = originalPath;
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null when PATH is empty", () => {
|
||||
process.env.PATH = "";
|
||||
expect(findKiroCli()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when kiro-cli is not in PATH", () => {
|
||||
process.env.PATH = tempDir;
|
||||
expect(findKiroCli()).toBeNull();
|
||||
});
|
||||
|
||||
it("finds kiro-cli in PATH", () => {
|
||||
const cliPath = join(tempDir, "kiro-cli");
|
||||
writeFileSync(cliPath, "#!/bin/sh\nexit 0\n", "utf-8");
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(cliPath, 0o755);
|
||||
}
|
||||
|
||||
process.env.PATH = `${tempDir}${delimiter}${originalPath ?? ""}`;
|
||||
const result = findKiroCli();
|
||||
expect(result).toBe(cliPath);
|
||||
});
|
||||
|
||||
it("finds q binary as fallback", () => {
|
||||
const qPath = join(tempDir, "q");
|
||||
writeFileSync(qPath, "#!/bin/sh\nexit 0\n", "utf-8");
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(qPath, 0o755);
|
||||
}
|
||||
|
||||
process.env.PATH = `${tempDir}${delimiter}${originalPath ?? ""}`;
|
||||
const result = findKiroCli();
|
||||
expect(result).toBe(qPath);
|
||||
});
|
||||
|
||||
it("prefers kiro-cli over q when both exist", () => {
|
||||
const cliPath = join(tempDir, "kiro-cli");
|
||||
const qPath = join(tempDir, "q");
|
||||
writeFileSync(cliPath, "#!/bin/sh\nexit 0\n", "utf-8");
|
||||
writeFileSync(qPath, "#!/bin/sh\nexit 0\n", "utf-8");
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(cliPath, 0o755);
|
||||
chmodSync(qPath, 0o755);
|
||||
}
|
||||
|
||||
process.env.PATH = `${tempDir}${delimiter}${originalPath ?? ""}`;
|
||||
const result = findKiroCli();
|
||||
expect(result).toBe(cliPath);
|
||||
});
|
||||
|
||||
if (process.platform === "win32") {
|
||||
it("finds kiro-cli.cmd on Windows", () => {
|
||||
const cliPath = join(tempDir, "kiro-cli.cmd");
|
||||
writeFileSync(cliPath, "@echo off\nexit /b 0\n", "utf-8");
|
||||
|
||||
process.env.PATH = `${tempDir}${delimiter}${originalPath ?? ""}`;
|
||||
const result = findKiroCli();
|
||||
expect(result).toBe(cliPath);
|
||||
});
|
||||
|
||||
it("finds kiro-cli.exe on Windows", () => {
|
||||
const cliPath = join(tempDir, "kiro-cli.exe");
|
||||
writeFileSync(cliPath, "", "utf-8");
|
||||
|
||||
process.env.PATH = `${tempDir}${delimiter}${originalPath ?? ""}`;
|
||||
const result = findKiroCli();
|
||||
expect(result).toBe(cliPath);
|
||||
});
|
||||
|
||||
it("finds kiro-cli.bat on Windows", () => {
|
||||
const cliPath = join(tempDir, "kiro-cli.bat");
|
||||
writeFileSync(cliPath, "@echo off\nexit /b 0\n", "utf-8");
|
||||
|
||||
process.env.PATH = `${tempDir}${delimiter}${originalPath ?? ""}`;
|
||||
const result = findKiroCli();
|
||||
expect(result).toBe(cliPath);
|
||||
});
|
||||
}
|
||||
|
||||
it("searches multiple PATH entries", () => {
|
||||
const dir1 = join(tempDir, "dir1");
|
||||
const dir2 = join(tempDir, "dir2");
|
||||
mkdirSync(dir1, { recursive: true });
|
||||
mkdirSync(dir2, { recursive: true });
|
||||
|
||||
const cliPath = join(dir2, "kiro-cli");
|
||||
writeFileSync(cliPath, "#!/bin/sh\nexit 0\n", "utf-8");
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(cliPath, 0o755);
|
||||
}
|
||||
|
||||
process.env.PATH = `${dir1}${delimiter}${dir2}${delimiter}${originalPath ?? ""}`;
|
||||
const result = findKiroCli();
|
||||
expect(result).toBe(cliPath);
|
||||
});
|
||||
|
||||
it("returns first match when kiro-cli exists in multiple PATH entries", () => {
|
||||
const dir1 = join(tempDir, "dir1");
|
||||
const dir2 = join(tempDir, "dir2");
|
||||
mkdirSync(dir1, { recursive: true });
|
||||
mkdirSync(dir2, { recursive: true });
|
||||
|
||||
const cliPath1 = join(dir1, "kiro-cli");
|
||||
const cliPath2 = join(dir2, "kiro-cli");
|
||||
writeFileSync(cliPath1, "#!/bin/sh\nexit 0\n", "utf-8");
|
||||
writeFileSync(cliPath2, "#!/bin/sh\nexit 0\n", "utf-8");
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(cliPath1, 0o755);
|
||||
chmodSync(cliPath2, 0o755);
|
||||
}
|
||||
|
||||
process.env.PATH = `${dir1}${delimiter}${dir2}${delimiter}${originalPath ?? ""}`;
|
||||
const result = findKiroCli();
|
||||
expect(result).toBe(cliPath1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWSL2", () => {
|
||||
it("returns false on non-Linux platforms", () => {
|
||||
if (process.platform !== "linux") {
|
||||
expect(isWSL2()).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Note: We can't easily test the true case without mocking /proc/version
|
||||
// The function will return true only when running in actual WSL2
|
||||
});
|
||||
});
|
||||
89
extensions/kiro-auth/cli-detector.ts
Normal file
89
extensions/kiro-auth/cli-detector.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { existsSync, accessSync, constants } from "node:fs";
|
||||
import { delimiter, join } from "node:path";
|
||||
|
||||
/**
|
||||
* Binary names to search for in PATH.
|
||||
* kiro-cli is the primary name, q is an alias.
|
||||
*/
|
||||
const CLI_BINARY_NAMES = ["kiro-cli", "q"];
|
||||
|
||||
/**
|
||||
* Windows executable extensions to check.
|
||||
* Empty string is included to check the bare name first.
|
||||
*/
|
||||
const WINDOWS_EXTENSIONS = ["", ".cmd", ".bat", ".exe"];
|
||||
|
||||
/**
|
||||
* Checks if a file exists and is executable.
|
||||
* On Windows, existence is sufficient (no X_OK check needed).
|
||||
* @param filePath Path to check
|
||||
* @returns true if file exists and is executable
|
||||
*/
|
||||
function isExecutable(filePath: string): boolean {
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
return existsSync(filePath);
|
||||
}
|
||||
accessSync(filePath, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for kiro-cli in the system PATH.
|
||||
* On Windows, also checks .cmd, .bat, .exe extensions.
|
||||
* @returns Full path to kiro-cli binary, or null if not found
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const cliPath = findKiroCli();
|
||||
* if (cliPath) {
|
||||
* console.log(`Found kiro-cli at: ${cliPath}`);
|
||||
* } else {
|
||||
* console.log("kiro-cli not found");
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function findKiroCli(): string | null {
|
||||
const pathEnv = process.env.PATH ?? "";
|
||||
if (!pathEnv) return null;
|
||||
|
||||
const pathEntries = pathEnv.split(delimiter).filter(Boolean);
|
||||
const extensions = process.platform === "win32" ? WINDOWS_EXTENSIONS : [""];
|
||||
|
||||
for (const name of CLI_BINARY_NAMES) {
|
||||
for (const dir of pathEntries) {
|
||||
for (const ext of extensions) {
|
||||
const candidate = join(dir, name + ext);
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current environment is WSL2.
|
||||
* Reads /proc/version on Linux to detect WSL2.
|
||||
* @returns true if running in WSL2
|
||||
*/
|
||||
export function isWSL2(): boolean {
|
||||
if (process.platform !== "linux") return false;
|
||||
|
||||
try {
|
||||
const { readFileSync } = require("node:fs") as typeof import("node:fs");
|
||||
const version = readFileSync("/proc/version", "utf8");
|
||||
// WSL2 includes "microsoft" and "WSL2" in /proc/version
|
||||
return (
|
||||
version.toLowerCase().includes("microsoft") &&
|
||||
version.toLowerCase().includes("wsl2")
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
120
extensions/kiro-auth/index.ts
Normal file
120
extensions/kiro-auth/index.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { extractKiroCliToken, isTokenExpired } from "./cli-credentials.js";
|
||||
import { findKiroCli } from "./cli-detector.js";
|
||||
|
||||
const PROVIDER_ID = "kiro";
|
||||
const PROVIDER_LABEL = "Kiro";
|
||||
const DEFAULT_MODEL = "kiro/auto";
|
||||
|
||||
const kiroPlugin = {
|
||||
id: "kiro-auth",
|
||||
name: "Kiro Auth",
|
||||
description: "Use kiro-cli for Kiro/Amazon Q Developer models",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: PROVIDER_LABEL,
|
||||
docsPath: "/providers/models",
|
||||
aliases: ["kiro-cli", "amazon-q", "q-developer"],
|
||||
envVars: [],
|
||||
auth: [
|
||||
{
|
||||
id: "cli",
|
||||
label: "Use installed kiro-cli",
|
||||
hint: "Requires kiro-cli to be installed and authenticated",
|
||||
kind: "custom",
|
||||
run: async (ctx) => {
|
||||
const spin = ctx.prompter.progress("Checking kiro-cli...");
|
||||
|
||||
// Check CLI is installed
|
||||
const cliPath = findKiroCli();
|
||||
if (!cliPath) {
|
||||
spin.stop("kiro-cli not found");
|
||||
await ctx.prompter.note(
|
||||
"Install kiro-cli first:\n\n" +
|
||||
" brew install kiro-cli\n" +
|
||||
" # or\n" +
|
||||
" curl -fsSL https://cli.kiro.dev/install | bash\n\n" +
|
||||
"Then authenticate:\n" +
|
||||
" kiro-cli chat # will prompt for login\n\n" +
|
||||
"Note: On Windows, kiro-cli requires WSL2.",
|
||||
"Installation required",
|
||||
);
|
||||
throw new Error("kiro-cli not installed");
|
||||
}
|
||||
|
||||
// Check credentials exist
|
||||
spin.update("Checking credentials...");
|
||||
const token = extractKiroCliToken();
|
||||
if (!token) {
|
||||
spin.stop("Not authenticated");
|
||||
await ctx.prompter.note(
|
||||
"Run `kiro-cli chat` to authenticate.\n" +
|
||||
"The CLI will guide you through SSO login.",
|
||||
"Authentication required",
|
||||
);
|
||||
throw new Error(
|
||||
"kiro-cli not authenticated. Run `kiro-cli chat` first.",
|
||||
);
|
||||
}
|
||||
|
||||
// Check token not expired
|
||||
if (isTokenExpired(token)) {
|
||||
spin.stop("Token expired");
|
||||
await ctx.prompter.note(
|
||||
"Your kiro-cli token has expired.\n" +
|
||||
"Run `kiro-cli chat` to re-authenticate.",
|
||||
"Re-authentication required",
|
||||
);
|
||||
throw new Error(
|
||||
"kiro-cli token expired. Run `kiro-cli chat` to refresh.",
|
||||
);
|
||||
}
|
||||
|
||||
spin.stop("kiro-cli ready");
|
||||
const profileId = "kiro:cli";
|
||||
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: PROVIDER_ID,
|
||||
access: token.access_token,
|
||||
refresh: token.refresh_token,
|
||||
expires: new Date(token.expires_at).getTime(),
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
notes: [
|
||||
`Using kiro-cli at: ${cliPath}`,
|
||||
`Region: ${token.region}`,
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
refreshOAuth: async (cred) => {
|
||||
// Re-read token from SQLite - kiro-cli handles the actual OAuth refresh
|
||||
const token = extractKiroCliToken();
|
||||
if (!token || isTokenExpired(token)) {
|
||||
throw new Error(
|
||||
"kiro-cli token expired. Run `kiro-cli chat` to refresh.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
...cred,
|
||||
access: token.access_token,
|
||||
refresh: token.refresh_token,
|
||||
expires: new Date(token.expires_at).getTime(),
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default kiroPlugin;
|
||||
11
extensions/kiro-auth/package.json
Normal file
11
extensions/kiro-auth/package.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@clawdbot/kiro-auth",
|
||||
"version": "2026.1.25",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Kiro/Amazon Q Developer OAuth provider plugin",
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user