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
7272 import { useIncomingShare } from "../sharing/IncomingShareProvider";
7373 import { detectComposerTrigger, replaceTextRange } from "@t3tools/shared/composerTrigger";
7474 import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
75−import { buildMobilePluginCommandItems } from "./plugin-command-menu";
75+import {
76+ buildMobilePluginCommandItems,
77+ reconcileComposerSelectionForTextChange,
78+} from "./plugin-command-menu";
7679 import { useMobilePluginCommands } from "./use-mobile-plugin-commands";
7780
7881 function NewTaskWorkspaceIcon(props: {
@@ -138,6 +141,7 @@ export function NewTaskDraftScreen(props: { · 59 unmodified lines
138141 start: flow.prompt.length,
139142 end: flow.prompt.length,
140143 }));
144+ const previousPromptLengthRef = useRef(flow.prompt.length);
141145 const { commands: pluginCommands, execute: executePluginCommand } = useMobilePluginCommands(
142146 selectedProject?.environmentId ?? null,
143147 );
@@ -169,13 +173,11 @@ export function NewTaskDraftScreen(props: { · 25 unmodified lines
169173 );
170174 useEffect(() => {
171175 const end = flow.prompt.length;
172− setComposerSelection((selection) => {
173− const start = Math.min(selection.start, end);
174− const selectionEnd = Math.min(selection.end, end);
175− return start === selection.start && selectionEnd === selection.end
176− ? selection
177− : { start, end: selectionEnd };
178− });
176+ const previousEnd = previousPromptLengthRef.current;
177+ previousPromptLengthRef.current = end;
178+ setComposerSelection((selection) =>
179+ reconcileComposerSelectionForTextChange(selection, previousEnd, end),
180+ );
179181 }, [flow.prompt.length]);
180182 const settingsSheetPresentation = useThreadSettingsSheetPresentation({
181183 editorRef: promptInputRef,
apps/mobile/src/features/threads/plugin-command-menu.test.ts+13−1
@@ -1,6 +1,9 @@
11 import { describe, expect, it } from "vite-plus/test";
22
3−import { buildMobilePluginCommandItems } from "./plugin-command-menu";
3+import {
4+ buildMobilePluginCommandItems,
5+ reconcileComposerSelectionForTextChange,
6+} from "./plugin-command-menu";
47
58 describe("buildMobilePluginCommandItems", () => {
69 it("renders only matching mobile command contributions", () => {
@@ -25,3 +28,12 @@ describe("buildMobilePluginCommandItems", () => { · 18 unmodified lines
2528 expect(items[0]?.type).toBe("plugin-command");
2629 });
2730 });
31+
32+describe("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
22
33 import type { ComposerCommandItem } from "./ComposerCommandPopover";
44
5+export 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+
519 export function buildMobilePluginCommandItems(
620 commands: ReadonlyArray<PluginCommand>,
721 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
88
99 ## contributions
1010
11−plugins 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.
11+plugins 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.
1212
1313 `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.
1414
packages/plugin-runtime/src/contract.ts+9−1
@@ -1,4 +1,12 @@
1−export interface Contribution<Data = unknown> {
1+export type ContributionData =
2+ | null
3+ | boolean
4+ | number
5+ | string
6+ | ReadonlyArray<ContributionData>
7+ | { readonly [key: string]: ContributionData };
8+
9+export interface Contribution<Data extends ContributionData = ContributionData> {
210 readonly id: string;
311 readonly label: string;
412 readonly data?: Data;
packages/plugin-runtime/src/runtime.ts+47−7
@@ -12,6 +12,7 @@ import * as Scope from "effect/Scope"; · 11 unmodified lines
1212
1313 import type {
1414 Contribution,
15+ ContributionData,
1516 PluginActivationContext,
1617 PluginDefinition,
1718 PluginRuntimeContributionSnapshot,
@@ -245,19 +246,58 @@ const sameStringRecord = ( · 227 unmodified lines
245246 return leftKeys.every((key) => left[key] === right[key]);
246247 };
247248
248−const deepFreeze = <Value>(value: Value): Value => {
249− if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
250− for (const nested of Object.values(value)) deepFreeze(nested);
251− return Object.freeze(value);
249+const cloneContributionData = (
250+ value: ContributionData,
251+ ancestors = new WeakSet<object>(),
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+ }
252294 };
253295
254296 const detachContribution = (contribution: Contribution): Contribution =>
255297 Object.freeze({
256298 id: contribution.id,
257299 label: contribution.label,
258− ...(contribution.data === undefined
259− ? {}
260− : { data: deepFreeze(structuredClone(contribution.data)) }),
300+ ...(contribution.data === undefined ? {} : { data: cloneContributionData(contribution.data) }),
261301 });
262302
263303 const snapshotDefinitions = (
packages/plugin-runtime/test/contributions.test.ts+45−0
@@ -347,6 +347,51 @@ describe("plugin runtime live contributions", () => { · 346 unmodified lines
347347 }),
348348 );
349349
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+
350395 it.effect("uses detached metadata as the default live contribution value", () =>
351396 Effect.gen(function* () {
352397 const runtime = yield* PluginRuntime.make();
scripts/release-smoke.ts+1−0
@@ -26,6 +26,7 @@ const workspaceFiles = [ · 25 unmodified lines
2626 "oxlint-plugin-t3code/package.json",
2727 "packages/client-runtime/package.json",
2828 "packages/contracts/package.json",
29+ "packages/plugin-runtime/package.json",
2930 "packages/shared/package.json",
3031 "packages/ssh/package.json",
3132 "packages/tailscale/package.json",