Skip to content
Open
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
31 changes: 30 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ import tsParser from '@typescript-eslint/parser';

export default [
js.configs.recommended,
{
// Claude Code hook scripts are CommonJS and run in Node.
files: ['.claude/hooks/**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
globals: {
require: 'readonly',
process: 'readonly',
console: 'readonly',
Buffer: 'readonly',
setTimeout: 'readonly',
setInterval: 'readonly',
clearTimeout: 'readonly',
clearInterval: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
},
},
},
{
files: ['**/*.ts'],
languageOptions: {
Expand All @@ -24,8 +44,15 @@ export default [
clearInterval: 'readonly',
NodeJS: 'readonly',
global: 'readonly',
fetch: 'readonly',
Headers: 'readonly',
Request: 'readonly',
Response: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
// Web/Node globals
TextDecoder: 'readonly',
URLSearchParams: 'readonly',
// Jest testing globals
jest: 'readonly',
describe: 'readonly',
Expand Down Expand Up @@ -77,9 +104,11 @@ export default [
'node_modules/',
'dist/',
'coverage/',
'*.js',
'.claude/**',
'**/*.js',
'!jest.config.js',
'!eslint.config.js',
'!.claude/hooks/**/*.js',
'tests/e2e/auth-persistence.test.ts'
]
}
Expand Down
55 changes: 40 additions & 15 deletions index-codemode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import winston from "winston";

import { TokenManager } from "./src/auth/TokenManager.js";
import { AuthManager } from "./src/auth/AuthManager.js";
import { AuthManager, AuthState } from "./src/auth/AuthManager.js";
import { loadWorkspaceSpec } from "./src/codemode/loadWorkspaceSpec.js";
import { runExecuteCode, runSearchCode } from "./src/codemode/sandbox.js";
import { makeGoogleApiRequest } from "./src/codemode/apiHost.js";
Expand Down Expand Up @@ -66,24 +66,49 @@ async function main() {
};
});

// Auth bootstrap (reuse existing TokenManager/AuthManager).
// Initialize TokenManager singleton (used indirectly by AuthManager).
TokenManager.getInstance(logger);
// Auth bootstrap: follow the SAME pathing + key expectations as index.ts (main tree).
// This intentionally avoids inventing new env var names.

// Load OAuth keys from the same place index.ts uses (TokenManager + env). We piggyback on TokenManager's config.
// The existing server uses GOOGLE_APPLICATION_CREDENTIALS / credentials files; keep that behavior.
const oauthKeysPath = process.env.GDRIVE_OAUTH_KEYS_PATH || process.env.GOOGLE_OAUTH_KEYS_PATH;
if (!oauthKeysPath) {
// Ensure encryption key is present (TokenManager requires it).
if (!process.env.GDRIVE_TOKEN_ENCRYPTION_KEY) {
logger.warn(
"Missing GDRIVE_OAUTH_KEYS_PATH/GOOGLE_OAUTH_KEYS_PATH; codemode execute will fail until OAuth keys are configured.",
"Missing GDRIVE_TOKEN_ENCRYPTION_KEY; codemode execute will be unavailable until it is set.",
);
}

// Default oauth key path matches README (./credentials/gcp-oauth.keys.json)
const oauthPath =
process.env.GDRIVE_OAUTH_PATH ??
new URL("../credentials/gcp-oauth.keys.json", import.meta.url).pathname;

// Initialize TokenManager singleton (loads encryption key, token store paths, etc.)
let auth: AuthManager | null = null;
if (oauthKeysPath) {
const keys = JSON.parse(await (await import("node:fs/promises")).readFile(oauthKeysPath, "utf8"));
auth = AuthManager.getInstance(keys, logger);
await auth.initialize();
try {
TokenManager.getInstance(logger);

const fs = await import("node:fs");
if (!fs.existsSync(oauthPath)) {
logger.warn(`OAuth keys not found at: ${oauthPath}`);
} else {
const keysContent = (await import("node:fs/promises")).readFile(oauthPath, "utf8");
const keys = JSON.parse(await keysContent) as { web?: unknown; installed?: unknown };
const oauthKeys = (keys as { web?: unknown; installed?: unknown }).web ??
(keys as { web?: unknown; installed?: unknown }).installed;

if (!oauthKeys || typeof oauthKeys !== "object") {
logger.warn("Invalid OAuth keys format. Expected 'web' or 'installed'.");
} else {
auth = AuthManager.getInstance(oauthKeys as never, logger);
await auth.initialize();
if (auth.getState() === AuthState.UNAUTHENTICATED) {
auth = null;
logger.warn("Not authenticated yet. Run: node ./dist/index.js auth");
}
}
}
} catch (err) {
logger.warn("Auth bootstrap failed; codemode execute will be unavailable until fixed.", { err });
auth = null;
}

const spec = await loadWorkspaceSpec();
Expand All @@ -95,7 +120,7 @@ async function main() {

server.setRequestHandler(CallToolRequestSchema, async (req) => {
const { name, arguments: args } = req.params;
const code = (args as any)?.code;
const code = (args as { code?: unknown } | undefined)?.code;
if (typeof code !== "string" || !code.trim()) {
throw new Error("Missing required argument: code (string)");
}
Expand All @@ -108,7 +133,7 @@ async function main() {
if (name === "execute") {
if (!auth) {
throw new Error(
"Auth not configured. Set GDRIVE_OAUTH_KEYS_PATH (or GOOGLE_OAUTH_KEYS_PATH) to enable execute().",
"Auth not ready. Follow README: set GDRIVE_TOKEN_ENCRYPTION_KEY and place credentials/gcp-oauth.keys.json, then run: node ./dist/index.js auth",
);
}
const apiRequest = makeGoogleApiRequest({ auth });
Expand Down
35 changes: 27 additions & 8 deletions src/codemode/apiHost.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
import { AuthManager } from "../auth/AuthManager.js";

type AnyObj = Record<string, any>;
type AnyObj = Record<string, unknown>;

export type ApiRequest = {
method: string;
path: string;
query?: Record<string, string | number | boolean | null | undefined>;
headers?: Record<string, string>;
body?: any;
body?: unknown;
};

function toQueryString(q: ApiRequest["query"]): string {
if (!q) return "";
if (!q) {
return "";
}

const params = new URLSearchParams();
for (const [k, v] of Object.entries(q)) {
if (v === undefined || v === null) continue;
if (v === undefined || v === null) {
continue;
}
params.set(k, String(v));
}

const s = params.toString();
return s ? `?${s}` : "";
}
Expand All @@ -34,6 +40,7 @@ export function makeGoogleApiRequest({
const r = req as ApiRequest;
const method = (r.method || "GET").toUpperCase();
const p = r.path;

if (!p || typeof p !== "string" || !p.startsWith("/")) {
throw new Error("api.request requires { path: string starting with '/' }");
}
Expand All @@ -50,21 +57,25 @@ export function makeGoogleApiRequest({
const oauth = auth.getOAuth2Client();
const tokenResp = await oauth.getAccessToken();
const token = tokenResp?.token;
if (!token) throw new Error("No access token available; authenticate first");
if (!token) {
throw new Error("No access token available; authenticate first");
}

const qs = toQueryString(r.query);
const url = `https://www.googleapis.com${p}${qs}`;

const headers: Record<string, string> = {
"authorization": `Bearer ${token}`,
authorization: `Bearer ${token}`,
"user-agent": userAgent,
...(r.headers || {}),
};

let body: string | undefined;
if (r.body !== undefined && r.body !== null && method !== "GET" && method !== "HEAD") {
body = typeof r.body === "string" ? r.body : JSON.stringify(r.body);
if (!headers["content-type"]) headers["content-type"] = "application/json";
if (!headers["content-type"]) {
headers["content-type"] = "application/json";
}
}

const resp = await fetch(url, { method, headers, body: body ?? null });
Expand All @@ -78,7 +89,15 @@ export function makeGoogleApiRequest({
const contentType = resp.headers.get("content-type") || "";
const isJson = contentType.includes("application/json");

const data = isJson ? (() => { try { return JSON.parse(text); } catch { return text; } })() : text;
const data = isJson
? (() => {
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
})()
: text;

return {
ok: resp.ok,
Expand Down
24 changes: 16 additions & 8 deletions src/codemode/loadWorkspaceSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import path from "node:path";
import YAML from "js-yaml";
import $RefParser from "@apidevtools/json-schema-ref-parser";

type AnyObj = Record<string, any>;
type AnyObj = Record<string, unknown>;

const SPEC_DIR = path.join(process.cwd(), "specs", "openapi", "googleapis.com");

Expand All @@ -28,22 +28,28 @@ function mtimeOf(files: string[]) {
let max = 0;
for (const f of files) {
const st = fs.statSync(f);
if (st.mtimeMs > max) max = st.mtimeMs;
if (st.mtimeMs > max) {
max = st.mtimeMs;
}
}
return max;
}

function readYaml(filePath: string): AnyObj {
const raw = fs.readFileSync(filePath, "utf8");
const doc = YAML.load(raw);
if (!doc || typeof doc !== "object") throw new Error(`Invalid YAML spec: ${filePath}`);
const doc: unknown = YAML.load(raw);
if (!doc || typeof doc !== "object") {
throw new Error(`Invalid YAML spec: ${filePath}`);
}
return doc as AnyObj;
}

export async function loadWorkspaceSpec(): Promise<AnyObj> {
const files = SPEC_FILES.map((f) => path.join(SPEC_DIR, f));
const mtimeMs = mtimeOf(files);
if (_cache && _cache.mtimeMs === mtimeMs) return _cache.spec;
if (_cache && _cache.mtimeMs === mtimeMs) {
return _cache.spec;
}

// Dereference each spec independently to avoid component name collisions.
const derefSpecs: AnyObj[] = [];
Expand All @@ -56,19 +62,21 @@ export async function loadWorkspaceSpec(): Promise<AnyObj> {
}

// Merge into a single spec surface.
const mergedPaths: Record<string, unknown> = {};
const merged: AnyObj = {
openapi: "3.0.0",
info: {
title: "Google Workspace APIs (Drive/Sheets/Docs/Gmail/Calendar/Forms/Script)",
version: "codemode-1",
},
servers: [{ url: "https://www.googleapis.com" }],
paths: {},
paths: mergedPaths,
};

for (const s of derefSpecs) {
if (s?.paths && typeof s.paths === "object") {
Object.assign(merged.paths, s.paths);
const paths = s.paths;
if (paths && typeof paths === "object") {
Object.assign(mergedPaths, paths as Record<string, unknown>);
}
}

Expand Down
28 changes: 14 additions & 14 deletions src/codemode/sandbox.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import ivm from "isolated-vm";

type AnyObj = Record<string, any>;
type AnyObj = Record<string, unknown>;

export type SandboxLimits = {
timeoutMs: number;
Expand All @@ -26,7 +26,7 @@ export async function runSearchCode({
code: string;
spec: AnyObj;
limits: SandboxLimits;
}): Promise<any> {
}): Promise<unknown> {
const isolate = new ivm.Isolate({ memoryLimit: limits.memoryMb });
const context = await isolate.createContext();
const jail = context.global;
Expand All @@ -39,11 +39,11 @@ export async function runSearchCode({
await script.run(context, { timeout: limits.timeoutMs });

const fnRef = await jail.get("__run", { reference: true });
const result = await fnRef.apply(
undefined,
[{ spec }],
{ timeout: limits.timeoutMs, arguments: { copy: true }, result: { copy: true } },
);
const result = await fnRef.apply(undefined, [{ spec }], {
timeout: limits.timeoutMs,
arguments: { copy: true },
result: { copy: true },
});
return result;
}

Expand All @@ -53,9 +53,9 @@ export async function runExecuteCode({
limits,
}: {
code: string;
apiRequest: (req: AnyObj) => Promise<any>;
apiRequest: (req: AnyObj) => Promise<unknown>;
limits: SandboxLimits;
}): Promise<any> {
}): Promise<unknown> {
const isolate = new ivm.Isolate({ memoryLimit: limits.memoryMb });
const context = await isolate.createContext();
const jail = context.global;
Expand All @@ -82,10 +82,10 @@ export async function runExecuteCode({
await (await isolate.compileScript(compilePrelude(code))).run(context, { timeout: limits.timeoutMs });

const fnRef = await jail.get("__run", { reference: true });
const result = await fnRef.apply(
undefined,
[{ api: { request: true } }],
{ timeout: limits.timeoutMs, arguments: { copy: true }, result: { copy: true } },
);
const result = await fnRef.apply(undefined, [{ api: { request: true } }], {
timeout: limits.timeoutMs,
arguments: { copy: true },
result: { copy: true },
});
return result;
}