Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c8f8282
feat: sandbox plugin JavaScript with CSP
1-DARK Oct 11, 2026
c6abee3
Merge branch 'main' into feat/sandbox-plugin-js-csp-secrets
1-DARK Oct 12, 2026
5d3f481
fix: address review feedback (slot callbacks via RPC, CSP, read-only …
1-DARK Oct 12, 2026
8aa4cc4
Merge branch 'main' into feat/sandbox-plugin-js-csp-secrets
1-DARK Oct 12, 2026
55d5993
Merge branch 'main' into feat/sandbox-plugin-js-csp-secrets
1-DARK Oct 13, 2026
e0f5c0e
Merge branch 'main' into feat/sandbox-plugin-js-csp-secrets
debba Oct 13, 2026
2b077ea
fix: enforce read-only queries for sandboxed plugins
1-DARK Oct 13, 2026
0fa2640
Merge main and enforce sandbox plugin read-only queries
1-DARK Oct 13, 2026
78bf2b0
Merge upstream main into sandbox plugin branch
1-DARK Oct 13, 2026
04de575
Fix query classification after upstream merge
1-DARK Oct 13, 2026
739eaaf
Merge branch 'main' into feat/sandbox-plugin-js-csp-secrets
1-DARK Oct 13, 2026
6d30b8d
Merge branch 'main' into feat/sandbox-plugin-js-csp-secrets
1-DARK Oct 14, 2026
5bb3434
Merge remote-tracking branch 'upstream/main' into feat/sandbox-plugin…
1-DARK Oct 14, 2026
8ca6c6e
fix: adapt plugin slot tests and remove query row cap
1-DARK Oct 14, 2026
23f5737
Merge remote-tracking branch 'origin/feat/sandbox-plugin-js-csp-secre…
1-DARK Oct 14, 2026
b7eaa18
Merge branch 'main' into feat/sandbox-plugin-js-csp-secrets
debba Oct 14, 2026
64a3c5c
Merge branch 'main' into feat/sandbox-plugin-js-csp-secrets
1-DARK Oct 14, 2026
6bafdcc
Merge upstream/main into feat/sandbox-plugin-js-csp-secrets
1-DARK Oct 17, 2026
44cb71e
Merge remote-tracking branch 'origin/feat/sandbox-plugin-js-csp-secre…
1-DARK Oct 17, 2026
3e7329a
fix: preserve compatibility for published plugin slots
1-DARK Oct 17, 2026
801dd29
Merge upstream/main into feat/sandbox-plugin-js-csp-secrets
1-DARK Oct 19, 2026
ea41be7
fix: address sandbox plugin compatibility feedback
1-DARK Oct 19, 2026
4f7a967
Merge branch 'main' into feat/sandbox-plugin-js-csp-secrets
1-DARK Oct 20, 2026
81b540c
test: raise timeout for Kubernetes connection test
1-DARK Oct 21, 2026
51beddf
Merge upstream/main into feat/sandbox-plugin-js-csp-secrets
1-DARK Oct 21, 2026
6cc89d8
fix: pass sandbox plugin CI checks
1-DARK Oct 21, 2026
387b7d8
Merge upstream/main into feat/sandbox-plugin-js-csp-secrets
1-DARK Oct 25, 2026
f10efe8
fix(ci): format, refresh web inventory, move sandbox protocol into de…
1-DARK Oct 25, 2026
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: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:plugin-guest": "node packages/plugin-guest/build.mjs",
"lint": "eslint . && node scripts/check-theme-tokens.mjs",
"lint:theme": "node scripts/check-theme-tokens.mjs",
"test:contrast": "vitest run tests/utils/themeContrast.test.ts",
Expand Down Expand Up @@ -96,6 +97,7 @@
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"esbuild": "^0.27.4",
"gitnexus": "^1.6.9",
"globals": "^16.5.0",
"jsdom": "^28.1.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/create-plugin/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { titleCase, validateDbType, validateName, validateQuote } from "./valida
// Resolved at runtime so `--version` can never drift from package.json again.
const PACKAGE_VERSION = (createRequire(import.meta.url)("../package.json") as { version: string }).version;
// Caret range written into scaffolded plugins; bump alongside packages/plugin-api.
const PLUGIN_API_VERSION = "0.2.0";
const PLUGIN_API_VERSION = "0.3.0";
const MIN_TABULARIS_VERSION = "0.9.20";

function main(argv: string[]): number {
Expand Down
3 changes: 2 additions & 1 deletion packages/create-plugin/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"noImplicitAny": true,
"noUnusedLocals": true,
Expand All @@ -17,7 +18,7 @@
"resolveJsonModule": true,
"verbatimModuleSyntax": true,
"outDir": "dist",
"rootDir": "src"
"rootDir": "../.."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: rootDir points to repo root instead of package source directory

rootDir: "../.." (repo root) while include: ["src/**/*"] resolves to packages/create-plugin/src/**/*. This mismatch causes TypeScript to emit declaration paths like packages/create-plugin/src/... under dist/, which is likely unintended. Should be rootDir: "." or rootDir: "src".


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: rootDir points to repository root (../..) instead of package source directory

This causes TypeScript to include all repo files in compilation, slowing down builds and potentially causing conflicts. Should be "rootDir": "src" like before.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules", "scripts", "tests", "templates"]
Expand Down
9 changes: 6 additions & 3 deletions packages/explain/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,13 @@ SQLite `EXPLAIN QUERY PLAN` rows:
import { parseExplain, parseExplainFor, parseRawExplain } from "@tabularis/explain";

// Sniff the format of a pasted blob or an uploaded file:
const plan = parseExplain(raw);
const plan = await parseExplain(raw);

// Or name the engine when the caller knows it:
const hinted = parseExplainFor(raw, "mysql");
const hinted = await parseExplainFor(raw, "mysql");

// Or parse the raw payload a host driver handed over:
const fromDriver = parseRawExplain({ engine, format, payload, original_query });
const fromDriver = await parseRawExplain({ engine, format, payload, original_query });
```

A host obtains the bytes however it likes — a driver, a pasted textarea, an
Expand All @@ -86,6 +86,9 @@ registerExplainParser({
```

Import the parser package before calling `parseRawExplain` or source detection.
These helpers are asynchronous because plugin parsers run in isolated guest
frames. Await `detectFormat`, `detectFormatFor`, `parseExplain`,
`parseExplainFor`, `parseRawExplain`, and `resolveExplainOutput`.
Registering the same format again replaces it, which supports plugin upgrades;
call `unregisterExplainParser(format)` when unloading it.

Expand Down
35 changes: 17 additions & 18 deletions packages/explain/src/parsers/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ export function explainEngineFromDriverName(name: string): ExplainEngine | null
*
* Recognises the two Postgres shapes before trying registered custom sniffers.
*/
export function detectFormat(raw: string): ExplainSourceFormat {
return detectFormatFor(raw, null);
export async function detectFormat(raw: string): Promise<ExplainSourceFormat> {
return await detectFormatFor(raw, null);
}

/**
Expand All @@ -108,19 +108,19 @@ export function detectFormat(raw: string): ExplainSourceFormat {
* Built-in hints retain their historical decisions. Custom parsers are tried
* in registry order and only through their side-effect-free sniffers.
*/
export function detectFormatFor(
export async function detectFormatFor(
raw: string,
engine: ExplainEngine | null,
): ExplainSourceFormat {
): Promise<ExplainSourceFormat> {
switch (engine) {
case "postgres":
return detectPostgresFormat(raw);
return Promise.resolve(detectPostgresFormat(raw));
case "mysql":
if (looksLikeJson(raw)) return "mysql-json";
if (looksLikeJson(raw)) return Promise.resolve("mysql-json");
if (raw.trim() === "") {
throw new Error("Unsupported EXPLAIN file format: input is empty");
}
return "mysql-text";
return Promise.resolve("mysql-text");
case "sqlite":
throw new Error(
"SQLite EXPLAIN QUERY PLAN has no text form here: pass its " +
Expand All @@ -129,16 +129,14 @@ export function detectFormatFor(
case null: {
const builtinFormat = detectPostgresFormatOrNull(raw);
if (builtinFormat !== null) return builtinFormat;

const customFormat = sniffRegisteredFormat(raw, null);
const customFormat = await sniffRegisteredFormat(raw, null);
if (customFormat !== null) return customFormat;

throw new Error(
"Unsupported EXPLAIN file format: expected Postgres JSON or text output",
);
}
default: {
const format = sniffRegisteredFormat(raw, engine);
const format = await sniffRegisteredFormat(raw, engine);
if (format !== null) return format;
throw new Error(`Unsupported EXPLAIN file format for engine '${engine}'`);
}
Expand All @@ -161,10 +159,10 @@ function detectPostgresFormatOrNull(
return null;
}

function sniffRegisteredFormat(
async function sniffRegisteredFormat(
raw: string,
engine: ExplainEngine | null,
): ExplainSourceFormat | null {
): Promise<ExplainSourceFormat | null> {
const normalizedEngine = engine?.trim().toLowerCase() ?? null;

for (const parser of listExplainParsers()) {
Expand All @@ -180,7 +178,7 @@ function sniffRegisteredFormat(
if (parser.sniff === undefined) continue;

try {
if (parser.sniff(raw)) return parser.format;
if (await parser.sniff(raw)) return parser.format;
} catch {
// A sniffer is advisory; one broken parser must not block later parsers.
}
Expand All @@ -206,16 +204,17 @@ function looksLikePostgresText(raw: string): boolean {
*
* Equivalent to `parseExplainFor(raw, null)`.
*/
export function parseExplain(raw: string): ExplainPlan {
export function parseExplain(raw: string): Promise<ExplainPlan> {
return parseExplainFor(raw, null);
}

/** Parse a payload, using the caller's engine hint when there is one. */
export function parseExplainFor(
export async function parseExplainFor(
raw: string,
engine: ExplainEngine | null,
): ExplainPlan {
return parserFor(detectFormatFor(raw, engine)).parse(raw);
): Promise<ExplainPlan> {
const format = await detectFormatFor(raw, engine);
return await parserFor(format).parse(raw);
}

/**
Expand Down
8 changes: 4 additions & 4 deletions packages/explain/src/raw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export type ExplainQueryOutput =
| { kind: "plan"; plan: ExplainPlan };

/** Parse a driver's raw EXPLAIN payload into a plan. */
export function parseRawExplain(raw: RawExplainOutput): ExplainPlan {
export async function parseRawExplain(raw: RawExplainOutput): Promise<ExplainPlan> {
const parser = getExplainParser(raw.format);
if (parser === null) {
throw new Error(
Expand All @@ -53,7 +53,7 @@ export function parseRawExplain(raw: RawExplainOutput): ExplainPlan {
);
}

const plan = parser.parse(raw.payload);
const plan = await parser.parse(raw.payload);
return {
...plan,
driver: raw.engine,
Expand All @@ -62,6 +62,6 @@ export function parseRawExplain(raw: RawExplainOutput): ExplainPlan {
}

/** Normalise either shape of `ExplainQueryOutput` into a plan. */
export function resolveExplainOutput(output: ExplainQueryOutput): ExplainPlan {
return output.kind === "plan" ? output.plan : parseRawExplain(output.raw);
export function resolveExplainOutput(output: ExplainQueryOutput): Promise<ExplainPlan> {
return output.kind === "plan" ? Promise.resolve(output.plan) : parseRawExplain(output.raw);
}
4 changes: 2 additions & 2 deletions packages/explain/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ export interface RegisteredExplainParser {
/** Human label for format pickers. */
readonly label?: string;
/** Parse the raw payload or throw an Error. */
parse(payload: string): ExplainPlan;
parse(payload: string): ExplainPlan | Promise<ExplainPlan>;
/** Cheap, side-effect-free source detection. */
sniff?(payload: string): boolean;
sniff?(payload: string): boolean | Promise<boolean>;
}

const builtinParsersByFormat = new Map(
Expand Down
38 changes: 19 additions & 19 deletions packages/explain/tests/parsers/postgres.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,22 +48,22 @@ const POSTGRES_NESTED = `[

describe("postgres parsers", () => {
describe("detectFormat", () => {
it("accepts a JSON array", () => {
expect(detectFormat(" \n [\n {} ]")).toBe("postgres-json");
it("accepts a JSON array", async () => {
expect(await detectFormat(" \n [\n {} ]")).toBe("postgres-json");
});

it("accepts a JSON object", () => {
expect(detectFormat('{ "Plan": {} }')).toBe("postgres-json");
it("accepts a JSON object", async () => {
expect(await detectFormat('{ "Plan": {} }')).toBe("postgres-json");
});

it("rejects plain text without a cost header", () => {
expect(() =>
it("rejects plain text without a cost header", async () => {
await expect(
detectFormat("Seq Scan on users (cost=0.00..12.34 rows=100)"),
).toThrow(/Unsupported/);
).rejects.toThrow(/Unsupported/);
});

it("recognises text output with a cost header", () => {
expect(detectFormat(POSTGRES_TEXT_FLAT)).toBe("postgres-text");
it("recognises text output with a cost header", async () => {
expect(await detectFormat(POSTGRES_TEXT_FLAT)).toBe("postgres-text");
});
});

Expand Down Expand Up @@ -112,20 +112,20 @@ describe("postgres parsers", () => {
});

describe("parseExplain error paths", () => {
it("rejects an empty array", () => {
expect(() => parseExplain("[]")).toThrow(/empty/);
it("rejects an empty array", async () => {
await expect(parseExplain("[]")).rejects.toThrow(/empty/);
});

it("rejects a missing Plan key", () => {
expect(() => parseExplain('[{"NotAPlan": 1}]')).toThrow(/Plan/);
it("rejects a missing Plan key", async () => {
await expect(parseExplain('[{"NotAPlan": 1}]')).rejects.toThrow(/Plan/);
});

it("rejects invalid JSON", () => {
expect(() => parseExplain("[not json]")).toThrow(/Failed to parse/);
it("rejects invalid JSON", async () => {
await expect(parseExplain("[not json]")).rejects.toThrow(/Failed to parse/);
});

it("rejects an unsupported format", () => {
expect(() => parseExplain("-> Nested Loop (cost=0.00..1.23)")).toThrow(
it("rejects an unsupported format", async () => {
await expect(parseExplain("-> Nested Loop (cost=0.00..1.23)")).rejects.toThrow(
/Unsupported/,
);
});
Expand Down Expand Up @@ -192,8 +192,8 @@ describe("postgres parsers", () => {
});

describe("parseExplain routing", () => {
it("routes text output to the text parser", () => {
const plan = parseExplain(POSTGRES_TEXT_FLAT);
it("routes text output to the text parser", async () => {
const plan = await parseExplain(POSTGRES_TEXT_FLAT);
expect(plan.root.node_type).toBe("Seq Scan");
});
});
Expand Down
56 changes: 28 additions & 28 deletions packages/explain/tests/parsers/source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ const POSTGRES_TEXT = " Seq Scan on users (cost=0.00..12.34 rows=100 width=80)\

describe("source dispatch", () => {
describe("an engine hint reaches parsers that sniffing cannot", () => {
it("parses MySQL JSON when the engine is given", () => {
const plan = parseExplainFor(MYSQL_JSON, "mysql");
it("parses MySQL JSON when the engine is given", async () => {
const plan = await parseExplainFor(MYSQL_JSON, "mysql");

expect(plan.driver).toBe("mysql");
expect(plan.root.relation).toBe("users");
Expand All @@ -54,60 +54,60 @@ describe("source dispatch", () => {
expect(plan.raw_output).not.toBeNull();
});

it("keeps MariaDB optimizer time as planning time", () => {
const plan = parseExplainFor(MARIADB_ANALYZE_JSON, "mysql");
it("keeps MariaDB optimizer time as planning time", async () => {
const plan = await parseExplainFor(MARIADB_ANALYZE_JSON, "mysql");

expect(plan.planning_time_ms).toBe(1.875);
expect(plan.has_analyze_data, "r_* fields are actual data").toBe(true);
expect(plan.root.relation).toBe("orders");
});

it("parses MySQL text when the engine is given", () => {
const plan = parseExplainFor(MYSQL_TEXT, "mysql");
it("parses MySQL text when the engine is given", async () => {
const plan = await parseExplainFor(MYSQL_TEXT, "mysql");

expect(plan.driver).toBe("mysql");
expect(plan.root.node_type).toBe("Nested Loop");
expect(plan.has_analyze_data).toBe(true);
});

it("still fails on MySQL JSON without a hint, as before", () => {
it("still fails on MySQL JSON without a hint, as before", async () => {
// Sniffing sees a leading `{` and tries Postgres, which has no `Plan` key.
expect(() => parseExplain(MYSQL_JSON)).toThrow(/Plan/);
await expect(parseExplain(MYSQL_JSON)).rejects.toThrow(/Plan/);
});
});

describe("the unhinted path is unchanged", () => {
it("matches the previous sniffing behaviour", () => {
expect(detectFormat(POSTGRES_JSON)).toBe("postgres-json");
expect(detectFormat(POSTGRES_TEXT)).toBe("postgres-text");
expect(detectFormatFor(POSTGRES_JSON, null)).toBe(detectFormat(POSTGRES_JSON));
expect(() => detectFormat("not a plan at all")).toThrow(/Unsupported/);
it("matches the previous sniffing behaviour", async () => {
expect(await detectFormat(POSTGRES_JSON)).toBe("postgres-json");
expect(await detectFormat(POSTGRES_TEXT)).toBe("postgres-text");
expect(await detectFormatFor(POSTGRES_JSON, null)).toBe(await detectFormat(POSTGRES_JSON));
await expect(detectFormat("not a plan at all")).rejects.toThrow(/Unsupported/);
});

it("agrees with sniffing when a postgres hint is given", () => {
it("agrees with sniffing when a postgres hint is given", async () => {
for (const raw of [POSTGRES_JSON, POSTGRES_TEXT]) {
expect(detectFormatFor(raw, "postgres")).toBe(detectFormat(raw));
expect(await detectFormatFor(raw, "postgres")).toBe(await detectFormat(raw));
}
const plan = parseExplainFor(POSTGRES_JSON, "postgres");
const plan = await parseExplainFor(POSTGRES_JSON, "postgres");
expect(plan.driver).toBe("postgres");
});
});

describe("edges", () => {
it("reports that sqlite has no text form", () => {
expect(() => parseExplainFor("SCAN users", "sqlite")).toThrow(
it("reports that sqlite has no text form", async () => {
await expect(parseExplainFor("SCAN users", "sqlite")).rejects.toThrow(
/buildSqliteTree/,
);
});

it("rejects empty input for every engine", () => {
expect(() => parseExplainFor("", null)).toThrow();
expect(() => parseExplainFor("", "postgres")).toThrow();
expect(() => parseExplainFor(" \n ", "mysql")).toThrow();
it("rejects empty input for every engine", async () => {
await expect(parseExplainFor("", null)).rejects.toThrow();
await expect(parseExplainFor("", "postgres")).rejects.toThrow();
await expect(parseExplainFor(" \n ", "mysql")).rejects.toThrow();
});

it("reports MySQL JSON missing its query_block", () => {
expect(() => parseExplainFor('{"not_a_block": 1}', "mysql")).toThrow(
it("reports MySQL JSON missing its query_block", async () => {
await expect(parseExplainFor('{"not_a_block": 1}', "mysql")).rejects.toThrow(
/query_block/,
);
});
Expand All @@ -127,15 +127,15 @@ describe("source dispatch", () => {
});

describe("withSourceLabel", () => {
it("labels a plan with an empty original query", () => {
const plan = parseExplainFor(POSTGRES_JSON, "postgres");
it("labels a plan with an empty original query", async () => {
const plan = await parseExplainFor(POSTGRES_JSON, "postgres");
const labelled = withSourceLabel(plan, "plan.json");
expect(labelled.original_query).toBe("-- loaded from plan.json");
});

it("leaves a stamped original query alone", () => {
it("leaves a stamped original query alone", async () => {
const plan = {
...parseExplainFor(POSTGRES_JSON, "postgres"),
...(await parseExplainFor(POSTGRES_JSON, "postgres")),
original_query: "SELECT 1",
};
expect(withSourceLabel(plan, "plan.json").original_query).toBe("SELECT 1");
Expand Down
Loading