History

fix: harden plugin command catalog

t3-code[bot] committed 2 months ago

Commit 12b8e9e1 parent 596bd978 files · +140 −18Browse files

Unified · Split

8 files
  1. apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +10 −8
  2. apps/mobile/src/features/threads/plugin-command-menu.test.ts +13 −1
  3. apps/mobile/src/features/threads/plugin-command-menu.ts +14 −0
  4. packages/plugin-runtime/README.md +1 −1
  5. packages/plugin-runtime/src/contract.ts +9 −1
  6. packages/plugin-runtime/src/runtime.ts +47 −7
  7. packages/plugin-runtime/test/contributions.test.ts +45 −0
  8. scripts/release-smoke.ts +1 −0
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+10−8
@@ -72,7 +72,10 @@ import { · 71 unmodified lines
72import { useIncomingShare } from "../sharing/IncomingShareProvider";72import { useIncomingShare } from "../sharing/IncomingShareProvider";
73import { detectComposerTrigger, replaceTextRange } from "@t3tools/shared/composerTrigger";73import { detectComposerTrigger, replaceTextRange } from "@t3tools/shared/composerTrigger";
74import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";74import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
75import { buildMobilePluginCommandItems } from "./plugin-command-menu";75import {
76 buildMobilePluginCommandItems,
77 reconcileComposerSelectionForTextChange,
78} from "./plugin-command-menu";
76import { useMobilePluginCommands } from "./use-mobile-plugin-commands";79import { useMobilePluginCommands } from "./use-mobile-plugin-commands";
77 80
78function NewTaskWorkspaceIcon(props: {81function NewTaskWorkspaceIcon(props: {
@@ -138,6 +141,7 @@ export function NewTaskDraftScreen(props: { · 59 unmodified lines
138 start: flow.prompt.length,141 start: flow.prompt.length,
139 end: flow.prompt.length,142 end: flow.prompt.length,
140 }));143 }));
144 const previousPromptLengthRef = useRef(flow.prompt.length);
141 const { commands: pluginCommands, execute: executePluginCommand } = useMobilePluginCommands(145 const { commands: pluginCommands, execute: executePluginCommand } = useMobilePluginCommands(
142 selectedProject?.environmentId ?? null,146 selectedProject?.environmentId ?? null,
143 );147 );
@@ -169,13 +173,11 @@ export function NewTaskDraftScreen(props: { · 25 unmodified lines
169 );173 );
170 useEffect(() => {174 useEffect(() => {
171 const end = flow.prompt.length;175 const end = flow.prompt.length;
172 setComposerSelection((selection) => {176 const previousEnd = previousPromptLengthRef.current;
173 const start = Math.min(selection.start, end);177 previousPromptLengthRef.current = end;
174 const selectionEnd = Math.min(selection.end, end);178 setComposerSelection((selection) =>
175 return start === selection.start && selectionEnd === selection.end179 reconcileComposerSelectionForTextChange(selection, previousEnd, end),
176 ? selection180 );
177 : { start, end: selectionEnd };
178 });
179 }, [flow.prompt.length]);181 }, [flow.prompt.length]);
180 const settingsSheetPresentation = useThreadSettingsSheetPresentation({182 const settingsSheetPresentation = useThreadSettingsSheetPresentation({
181 editorRef: promptInputRef,183 editorRef: promptInputRef,
apps/mobile/src/features/threads/plugin-command-menu.test.ts+13−1
@@ -1,6 +1,9 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2 2
3import { buildMobilePluginCommandItems } from "./plugin-command-menu";3import {
4 buildMobilePluginCommandItems,
5 reconcileComposerSelectionForTextChange,
6} from "./plugin-command-menu";
4 7
5describe("buildMobilePluginCommandItems", () => {8describe("buildMobilePluginCommandItems", () => {
6 it("renders only matching mobile command contributions", () => {9 it("renders only matching mobile command contributions", () => {
@@ -25,3 +28,12 @@ describe("buildMobilePluginCommandItems", () => { · 18 unmodified lines
25 expect(items[0]?.type).toBe("plugin-command");28 expect(items[0]?.type).toBe("plugin-command");
26 });29 });
27});30});
31
32describe("reconcileComposerSelectionForTextChange", () => {
33 it("moves an end-positioned caret after prompt hydration", () => {
34 expect(reconcileComposerSelectionForTextChange({ start: 0, end: 0 }, 0, 14)).toEqual({
35 start: 14,
36 end: 14,
37 });
38 });
39});
apps/mobile/src/features/threads/plugin-command-menu.ts+14−0
@@ -2,6 +2,20 @@ import type { PluginCommand } from "@t3tools/contracts"; · 1 unmodified line
2 2
3import type { ComposerCommandItem } from "./ComposerCommandPopover";3import type { ComposerCommandItem } from "./ComposerCommandPopover";
4 4
5export function reconcileComposerSelectionForTextChange(
6 selection: { readonly start: number; readonly end: number },
7 previousLength: number,
8 nextLength: number,
9): { readonly start: number; readonly end: number } {
10 if (selection.start === previousLength && selection.end === previousLength) {
11 return { start: nextLength, end: nextLength };
12 }
13 return {
14 start: Math.min(selection.start, nextLength),
15 end: Math.min(selection.end, nextLength),
16 };
17}
18
5export function buildMobilePluginCommandItems(19export function buildMobilePluginCommandItems(
6 commands: ReadonlyArray<PluginCommand>,20 commands: ReadonlyArray<PluginCommand>,
7 query: string,21 query: string,
packages/plugin-runtime/README.md+1−1
@@ -8,7 +8,7 @@ cordis is not a dependency. a pure-only executor was rejected because plugin lif · 7 unmodified lines
8 8
9## contributions9## contributions
10 10
11plugins register detached declarative metadata and an optional host-only live value. snapshots and `contributions(slot)` expose only frozen metadata. executable values never cross the rpc boundary.11plugins register detached, deeply frozen, json-compatible declarative metadata and an optional host-only live value. snapshots and `contributions(slot)` expose only frozen metadata. executable values never cross the rpc boundary.
12 12
13`contributions(slot)` returns the committed generation with its entries. hosts must pass that generation to `useContribution(...)`; stale callers fail instead of invoking a handler from a different composition. invocation, reconciliation, and shutdown share the same transition authority, so a plugin scope cannot retire while its contribution is running.13`contributions(slot)` returns the committed generation with its entries. hosts must pass that generation to `useContribution(...)`; stale callers fail instead of invoking a handler from a different composition. invocation, reconciliation, and shutdown share the same transition authority, so a plugin scope cannot retire while its contribution is running.
14 14
packages/plugin-runtime/src/contract.ts+9−1
@@ -1,4 +1,12 @@
1export interface Contribution<Data = unknown> {1export type ContributionData =
2 | null
3 | boolean
4 | number
5 | string
6 | ReadonlyArray<ContributionData>
7 | { readonly [key: string]: ContributionData };
8
9export interface Contribution<Data extends ContributionData = ContributionData> {
2 readonly id: string;10 readonly id: string;
3 readonly label: string;11 readonly label: string;
4 readonly data?: Data;12 readonly data?: Data;
packages/plugin-runtime/src/runtime.ts+47−7
@@ -12,6 +12,7 @@ import * as Scope from "effect/Scope"; · 11 unmodified lines
12 12
13import type {13import type {
14 Contribution,14 Contribution,
15 ContributionData,
15 PluginActivationContext,16 PluginActivationContext,
16 PluginDefinition,17 PluginDefinition,
17 PluginRuntimeContributionSnapshot,18 PluginRuntimeContributionSnapshot,
@@ -245,19 +246,58 @@ const sameStringRecord = ( · 227 unmodified lines
245 return leftKeys.every((key) => left[key] === right[key]);246 return leftKeys.every((key) => left[key] === right[key]);
246};247};
247 248
248const deepFreeze = <Value>(value: Value): Value => {249const cloneContributionData = (
249 if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;250 value: ContributionData,
250 for (const nested of Object.values(value)) deepFreeze(nested);251 ancestors = new WeakSet<object>(),
251 return Object.freeze(value);252): ContributionData => {
253 if (value === null || typeof value === "string" || typeof value === "boolean") return value;
254 if (typeof value === "number") {
255 if (!Number.isFinite(value)) throw new TypeError("Contribution data numbers must be finite");
256 return value;
257 }
258 if (typeof value !== "object") {
259 throw new TypeError("Contribution data must contain only JSON-compatible values");
260 }
261 if (ancestors.has(value)) throw new TypeError("Contribution data cannot contain cycles");
262 ancestors.add(value);
263 try {
264 if (Array.isArray(value)) {
265 const clone: Array<ContributionData> = [];
266 for (let index = 0; index < value.length; index += 1) {
267 if (!Object.hasOwn(value, index)) {
268 throw new TypeError("Contribution data arrays cannot contain holes");
269 }
270 clone.push(cloneContributionData(value[index]!, ancestors));
271 }
272 return Object.freeze(clone);
273 }
274
275 const prototype = Object.getPrototypeOf(value);
276 if (prototype !== Object.prototype && prototype !== null) {
277 throw new TypeError("Contribution data objects must use a plain or null prototype");
278 }
279 const clone = createNullPrototypeRecord<ContributionData>();
280 for (const key of Reflect.ownKeys(value)) {
281 if (typeof key !== "string") {
282 throw new TypeError("Contribution data objects cannot use symbol keys");
283 }
284 const descriptor = Object.getOwnPropertyDescriptor(value, key);
285 if (descriptor === undefined || descriptor.enumerable !== true || !("value" in descriptor)) {
286 throw new TypeError("Contribution data objects must use enumerable data properties");
287 }
288 clone[key] = cloneContributionData(descriptor.value as ContributionData, ancestors);
289 }
290 return Object.freeze(clone);
291 } finally {
292 ancestors.delete(value);
293 }
252};294};
253 295
254const detachContribution = (contribution: Contribution): Contribution =>296const detachContribution = (contribution: Contribution): Contribution =>
255 Object.freeze({297 Object.freeze({
256 id: contribution.id,298 id: contribution.id,
257 label: contribution.label,299 label: contribution.label,
258 ...(contribution.data === undefined300 ...(contribution.data === undefined ? {} : { data: cloneContributionData(contribution.data) }),
259 ? {}
260 : { data: deepFreeze(structuredClone(contribution.data)) }),
261 });301 });
262 302
263const snapshotDefinitions = (303const snapshotDefinitions = (
packages/plugin-runtime/test/contributions.test.ts+45−0
@@ -347,6 +347,51 @@ describe("plugin runtime live contributions", () => { · 346 unmodified lines
347 }),347 }),
348 );348 );
349 349
350 it.effect("rejects non-declarative contribution data without replacing the composition", () =>
351 Effect.gen(function* () {
352 const runtime = yield* PluginRuntime.make();
353 const stable: PluginDefinition = {
354 id: "acme.commands",
355 version: "1.0.0",
356 activate(context) {
357 context.register("commands", { id: "status", label: "Status" }, Effect.void);
358 },
359 };
360 yield* runtime.reconcile([stable]);
361 const committed = yield* runtime.contributions("commands");
362 const circular: { self?: unknown } = {};
363 circular.self = circular;
364 const invalidValues: ReadonlyArray<unknown> = [
365 circular,
366 new Map([["status", true]]),
367 new Uint8Array([1]),
368 ];
369
370 for (const [index, data] of invalidValues.entries()) {
371 const failed = yield* Effect.exit(
372 runtime.reconcile([
373 {
374 id: "acme.commands",
375 version: `2.0.${index}`,
376 activate(context) {
377 context.register("commands", {
378 id: "status",
379 label: "Status",
380 data: data as never,
381 });
382 },
383 },
384 ]),
385 );
386 expect(Exit.isFailure(failed)).toBe(true);
387 if (Exit.isFailure(failed)) {
388 expect(Cause.squash(failed.cause)).toMatchObject({ _tag: "PluginCallbackError" });
389 }
390 expect((yield* runtime.contributions("commands")).generation).toBe(committed.generation);
391 }
392 }),
393 );
394
350 it.effect("uses detached metadata as the default live contribution value", () =>395 it.effect("uses detached metadata as the default live contribution value", () =>
351 Effect.gen(function* () {396 Effect.gen(function* () {
352 const runtime = yield* PluginRuntime.make();397 const runtime = yield* PluginRuntime.make();
scripts/release-smoke.ts+1−0
@@ -26,6 +26,7 @@ const workspaceFiles = [ · 25 unmodified lines
26 "oxlint-plugin-t3code/package.json",26 "oxlint-plugin-t3code/package.json",
27 "packages/client-runtime/package.json",27 "packages/client-runtime/package.json",
28 "packages/contracts/package.json",28 "packages/contracts/package.json",
29 "packages/plugin-runtime/package.json",
29 "packages/shared/package.json",30 "packages/shared/package.json",
30 "packages/ssh/package.json",31 "packages/ssh/package.json",
31 "packages/tailscale/package.json",32 "packages/tailscale/package.json",