@intentic/extension-manifest 1.317.0 → 1.319.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/dist/bundle.d.ts.map +1 -1
- package/dist/bundle.js +219 -7
- package/dist/bundle.js.map +1 -1
- package/dist/manifest.d.ts +6 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +9 -2
- package/dist/manifest.js.map +1 -1
- package/dist/permissions.d.ts +1 -0
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +45 -10
- package/dist/permissions.js.map +1 -1
- package/dist/points/capabilities.d.ts +2 -0
- package/dist/points/capabilities.d.ts.map +1 -1
- package/dist/points/capabilities.js +4 -0
- package/dist/points/capabilities.js.map +1 -1
- package/dist/points/index.d.ts +16 -0
- package/dist/points/index.d.ts.map +1 -1
- package/dist/points/index.js +3 -0
- package/dist/points/index.js.map +1 -1
- package/dist/points/side-views.d.ts +17 -0
- package/dist/points/side-views.d.ts.map +1 -0
- package/dist/points/side-views.js +20 -0
- package/dist/points/side-views.js.map +1 -0
- package/dist/points/viewers.js +1 -1
- package/dist/points/viewers.js.map +1 -1
- package/intentic-extension.schema.json +44 -1
- package/package.json +8 -3
- package/src/bundle.test.ts +91 -0
- package/src/bundle.ts +288 -9
- package/src/manifest.ts +13 -2
- package/src/permissions.test.ts +98 -0
- package/src/permissions.ts +72 -13
- package/src/points/capabilities.ts +7 -0
- package/src/points/index.ts +3 -0
- package/src/points/side-views.ts +34 -0
- package/src/points/viewers.ts +1 -1
- package/src/powers.test.ts +212 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { ExtensionManifestSchema } from "./manifest.js";
|
|
2
|
+
import { permissionProblem, sandboxRouteAllowed } from "./permissions.js";
|
|
3
|
+
|
|
4
|
+
// The glob grammar: each segment of the declared path is literal or a lone `*`, and a `*` matches one whole segment
|
|
5
|
+
// that the URL layer would route rather than resolve away.
|
|
6
|
+
|
|
7
|
+
describe("sandboxRouteAllowed", () => {
|
|
8
|
+
test.each([
|
|
9
|
+
["an exact route", "GET /panels", "GET", "/panels", true],
|
|
10
|
+
["the root", "GET /", "GET", "/", true],
|
|
11
|
+
["a trailing slash is its own route", "GET /panels", "GET", "/panels/", false],
|
|
12
|
+
["a `*` matches one segment", "POST /panels/*/start", "POST", "/panels/my-repo/start", true],
|
|
13
|
+
["two `*`s, two segments", "POST /workspace/repos/*/apps/*/start", "POST", "/workspace/repos/intentic/apps/web/start", true],
|
|
14
|
+
["a `*` matches an encoded slash, which stays inside its segment", "GET /git/*/log", "GET", "/git/a%2Fb/log", true],
|
|
15
|
+
["a `*` matches a segment holding dots", "GET /git/*/log", "GET", "/git/.config/log", true],
|
|
16
|
+
["a `*` matches a segment of dots beyond two", "GET /git/*/log", "GET", "/git/.../log", true],
|
|
17
|
+
["a `*` never matches an empty segment", "POST /panels/*/start", "POST", "/panels//start", false],
|
|
18
|
+
["a `*` never crosses a slash", "POST /panels/*/start", "POST", "/panels/a/b/start", false],
|
|
19
|
+
["a `*` never matches `..`, which resolves to /start", "POST /panels/*/start", "POST", "/panels/../start", false],
|
|
20
|
+
["a `*` never matches `.`", "POST /panels/*/start", "POST", "/panels/./start", false],
|
|
21
|
+
["a `*` never matches an encoded `..`", "DELETE /approvals/*", "DELETE", "/approvals/%2e%2E", false],
|
|
22
|
+
["a `*` never matches a half-encoded `..`", "DELETE /approvals/*", "DELETE", "/approvals/.%2e", false],
|
|
23
|
+
["a `*` never matches a backslash, which a URL reads as a slash", "POST /panels/*/start", "POST", "/panels/..\\..\\secrets/start", false],
|
|
24
|
+
["the query string is ignored", "GET /logs/file", "GET", "/logs/file?name=daemon.log&bytes=4096", true],
|
|
25
|
+
["the method is case-insensitive", "get /panels", "GET", "/panels", true],
|
|
26
|
+
["the asked method is case-insensitive", "GET /panels", "get", "/panels", true],
|
|
27
|
+
["the wrong method", "GET /panels/*/start", "POST", "/panels/a/start", false],
|
|
28
|
+
["an undeclared path", "GET /panels", "GET", "/secrets", false],
|
|
29
|
+
["a relative path", "GET /panels", "GET", "panels", false],
|
|
30
|
+
] as const)("%s", (_name, entry, method, path, allowed) => {
|
|
31
|
+
expect(sandboxRouteAllowed([entry], method, path)).toBe(allowed);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("any declared entry covering the call is enough", () => {
|
|
35
|
+
expect(sandboxRouteAllowed(["GET /panels", "POST /panels/*/start"], "POST", "/panels/a/start")).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("an empty permission list allows nothing", () => {
|
|
39
|
+
expect(sandboxRouteAllowed([], "GET", "/panels")).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("a malformed entry throws, naming itself", () => {
|
|
43
|
+
expect(() => sandboxRouteAllowed(["/panels"], "GET", "/panels")).toThrow(
|
|
44
|
+
`invalid permission "/panels": expected "<METHOD> <path-glob>", e.g. "GET /panels"`,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("permissionProblem", () => {
|
|
50
|
+
test.each(["GET /panels", "post /panels/*/start", " DELETE /approvals/* ", "PUT /secrets/*", "GET /", "HEAD /panels/"])("%s is well formed", (entry) => {
|
|
51
|
+
expect(permissionProblem(entry)).toBe(undefined);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test.each([
|
|
55
|
+
["/panels", `expected "<METHOD> <path-glob>", e.g. "GET /panels"`],
|
|
56
|
+
["GET", `expected "<METHOD> <path-glob>", e.g. "GET /panels"`],
|
|
57
|
+
["GET /panels extra", `expected "<METHOD> <path-glob>", e.g. "GET /panels"`],
|
|
58
|
+
["FETCH /panels", `"FETCH" is not one of GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS`],
|
|
59
|
+
["GET panels", `the path must start with "/"`],
|
|
60
|
+
["GET /logs/file?name=x", `the path may not hold "?", "#" or "\\": routes are matched on the path alone`],
|
|
61
|
+
["GET /panels#top", `the path may not hold "?", "#" or "\\": routes are matched on the path alone`],
|
|
62
|
+
["GET /panels\\x", `the path may not hold "?", "#" or "\\": routes are matched on the path alone`],
|
|
63
|
+
[
|
|
64
|
+
"GET /files/**",
|
|
65
|
+
`"**" is not a segment glob: a \`*\` stands alone between slashes and matches one whole segment, so \`**\` and a \`*\` inside a segment are not supported`,
|
|
66
|
+
],
|
|
67
|
+
[
|
|
68
|
+
"GET /files/*.json",
|
|
69
|
+
`"*.json" is not a segment glob: a \`*\` stands alone between slashes and matches one whole segment, so \`**\` and a \`*\` inside a segment are not supported`,
|
|
70
|
+
],
|
|
71
|
+
["GET /panels/../secrets", `".." is not a route segment: the URL resolves it away before any route sees it`],
|
|
72
|
+
["GET /panels/%2E/x", `"%2E" is not a route segment: the URL resolves it away before any route sees it`],
|
|
73
|
+
])("%s is refused", (entry, problem) => {
|
|
74
|
+
expect(permissionProblem(entry)).toBe(`invalid permission "${entry}": ${problem}`);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("a manifest's permissions", () => {
|
|
79
|
+
const parse = (permissions: object) =>
|
|
80
|
+
ExtensionManifestSchema.safeParse({ publisher: "acme", name: "demo", version: "1.0.0", engines: { intentic: "^1.0.0" }, permissions });
|
|
81
|
+
|
|
82
|
+
test("well-formed entries parse", () => {
|
|
83
|
+
expect(parse({ sandbox: ["GET /panels"], daemon: ["POST /panels/*/start"] }).data?.permissions).toEqual({
|
|
84
|
+
sandbox: ["GET /panels"],
|
|
85
|
+
daemon: ["POST /panels/*/start"],
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test.each(["sandbox", "daemon"])("a malformed %s entry fails the parse, at the entry, with the reason", (half) => {
|
|
90
|
+
expect(parse({ [half]: ["GET /panels", "GET /files/**"] }).error?.issues).toEqual([
|
|
91
|
+
{
|
|
92
|
+
code: "custom",
|
|
93
|
+
path: ["permissions", half, 1],
|
|
94
|
+
message: `invalid permission "GET /files/**": "**" is not a segment glob: a \`*\` stands alone between slashes and matches one whole segment, so \`**\` and a \`*\` inside a segment are not supported`,
|
|
95
|
+
},
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
});
|
package/src/permissions.ts
CHANGED
|
@@ -1,33 +1,92 @@
|
|
|
1
1
|
/* The sandbox-route permission model. */
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// An entry is "<METHOD> <path-glob>": an HTTP method, then an absolute path whose segments are each literal or a lone
|
|
4
|
+
// `*`. A `*` matches exactly one segment, and never a dot segment (`.` or `..`, percent-encoded or not) or one holding a
|
|
5
|
+
// backslash, since the URL layer resolves those away before any route sees them: `POST /panels/*/start` must not reach
|
|
6
|
+
// `/panels/../start`, which is `/start`. A `**` or a `*` inside a segment is refused rather than read in some way its
|
|
7
|
+
// author did not mean.
|
|
8
|
+
|
|
9
|
+
const METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]);
|
|
10
|
+
|
|
11
|
+
const DOT_SEGMENT = /^(?:\.|%2e){1,2}$/iu;
|
|
4
12
|
|
|
5
13
|
interface CompiledRoute {
|
|
6
14
|
readonly method: string;
|
|
7
|
-
|
|
15
|
+
// Each segment of the glob after its leading slash, undefined for a `*`.
|
|
16
|
+
readonly segments: readonly (string | undefined)[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Why a glob segment cannot be declared, or undefined when it can.
|
|
20
|
+
const segmentProblem = (segment: string): string | undefined => {
|
|
21
|
+
if (segment.includes("*") && segment !== "*") {
|
|
22
|
+
return `"${segment}" is not a segment glob: a \`*\` stands alone between slashes and matches one whole segment, so \`**\` and a \`*\` inside a segment are not supported`;
|
|
23
|
+
}
|
|
24
|
+
if (DOT_SEGMENT.test(segment)) {
|
|
25
|
+
return `"${segment}" is not a route segment: the URL resolves it away before any route sees it`;
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Why the entry is malformed, or undefined when it is well formed.
|
|
31
|
+
const syntaxProblem = (method: string, glob: string): string | undefined => {
|
|
32
|
+
if (glob === "") {
|
|
33
|
+
return `expected "<METHOD> <path-glob>", e.g. "GET /panels"`;
|
|
34
|
+
}
|
|
35
|
+
if (!METHODS.has(method.toUpperCase())) {
|
|
36
|
+
return `"${method}" is not one of ${[...METHODS].join(", ")}`;
|
|
37
|
+
}
|
|
38
|
+
if (!glob.startsWith("/")) {
|
|
39
|
+
return `the path must start with "/"`;
|
|
40
|
+
}
|
|
41
|
+
if (/[?#\\]/u.test(glob)) {
|
|
42
|
+
return `the path may not hold "?", "#" or "\\": routes are matched on the path alone`;
|
|
43
|
+
}
|
|
44
|
+
return glob.slice(1).split("/").map(segmentProblem).find((found) => found !== undefined);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
interface Entry {
|
|
48
|
+
readonly method: string;
|
|
49
|
+
readonly glob: string;
|
|
8
50
|
}
|
|
9
51
|
|
|
10
|
-
|
|
11
|
-
|
|
52
|
+
const split = (entry: string): Entry => {
|
|
53
|
+
const [, method = "", glob = ""] = /^(\S+)\s+(\S+)$/u.exec(entry.trim()) ?? [];
|
|
54
|
+
return { method, glob };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Why `entry` is not a permission, as the message a manifest parse reports, or undefined when it is one.
|
|
58
|
+
export const permissionProblem = (entry: string): string | undefined => {
|
|
59
|
+
const { method, glob } = split(entry);
|
|
60
|
+
const problem = syntaxProblem(method, glob);
|
|
61
|
+
return problem === undefined ? undefined : `invalid permission "${entry}": ${problem}`;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// The entry as a method and its glob's segments. Throws on a malformed one; the manifest parse refuses that first
|
|
65
|
+
// (manifest.ts), so this only fires on a list that never went through it.
|
|
12
66
|
const compile = (entry: string): CompiledRoute => {
|
|
13
|
-
const
|
|
14
|
-
if (
|
|
15
|
-
throw new Error(
|
|
67
|
+
const problem = permissionProblem(entry);
|
|
68
|
+
if (problem !== undefined) {
|
|
69
|
+
throw new Error(problem);
|
|
16
70
|
}
|
|
17
|
-
const method = entry
|
|
18
|
-
|
|
19
|
-
const source = `^${glob.split("*").map(escapeRegExp).join("[^/]+")}$`;
|
|
20
|
-
const regex = new RegExp(source);
|
|
21
|
-
return { method, test: (path) => regex.test(path) };
|
|
71
|
+
const { method, glob } = split(entry);
|
|
72
|
+
return { method: method.toUpperCase(), segments: glob.slice(1).split("/").map((segment) => (segment === "*" ? undefined : segment)) };
|
|
22
73
|
};
|
|
23
74
|
|
|
75
|
+
const segmentMatches = (glob: string | undefined, segment: string): boolean =>
|
|
76
|
+
glob === undefined ? segment !== "" && !segment.includes("\\") && !DOT_SEGMENT.test(segment) : glob === segment;
|
|
77
|
+
|
|
24
78
|
// Whether `method path` is covered by any of the declared permissions. The query string is ignored (routes are
|
|
25
79
|
// matched on path only), and the method is compared case-insensitively.
|
|
26
80
|
export const sandboxRouteAllowed = (permissions: readonly string[], method: string, path: string): boolean => {
|
|
27
81
|
const route = path.split("?")[0] ?? path;
|
|
28
82
|
const wanted = method.toUpperCase();
|
|
83
|
+
const segments = route.startsWith("/") ? route.slice(1).split("/") : [];
|
|
29
84
|
return permissions.some((entry) => {
|
|
30
85
|
const compiled = compile(entry);
|
|
31
|
-
return
|
|
86
|
+
return (
|
|
87
|
+
compiled.method === wanted &&
|
|
88
|
+
compiled.segments.length === segments.length &&
|
|
89
|
+
compiled.segments.every((glob, index) => segmentMatches(glob, segments[index] ?? ""))
|
|
90
|
+
);
|
|
32
91
|
});
|
|
33
92
|
};
|
|
@@ -184,6 +184,13 @@ export const CapabilityContributionSchema = z
|
|
|
184
184
|
probe: ProbeSchema.optional().describe(
|
|
185
185
|
"One authenticated request that tests this card's settings before they are saved, so a wrong token or an unreachable host is answered on the form rather than by a card that says 'not connected' afterwards.",
|
|
186
186
|
),
|
|
187
|
+
// Only narrows what the credential may do, so it carries no effect: nothing here grants the extension reach.
|
|
188
|
+
hosts: z
|
|
189
|
+
.array(z.string().min(1))
|
|
190
|
+
.optional()
|
|
191
|
+
.describe(
|
|
192
|
+
"The hosts this card's credential is meant for, as templates over the fields like `env` (`api.github.com`, `*.githubusercontent.com`, `${url}`); a value that comes out as a URL counts as its host. The sandbox limits the credential's `{{secret:…}}` reference to them by default, so a use aimed anywhere else asks a person first. The owner can change or lift the list on the Secrets view.",
|
|
193
|
+
),
|
|
187
194
|
// The first way a card served tools, kept one release as an alias of `contributes.tools` with this card as
|
|
188
195
|
// `perCard` and this value as `path` (points/tools.ts); a manifest declaring both is served by `tools`.
|
|
189
196
|
mcp: z
|
package/src/points/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { filesPoint } from "./files.js";
|
|
|
11
11
|
import { listenerPoint } from "./listener.js";
|
|
12
12
|
import { processesPoint } from "./processes.js";
|
|
13
13
|
import { settingsPoint } from "./settings.js";
|
|
14
|
+
import { sideViewsPoint } from "./side-views.js";
|
|
14
15
|
import { toolsPoint } from "./tools.js";
|
|
15
16
|
import { viewersPoint } from "./viewers.js";
|
|
16
17
|
import { viewsPoint } from "./views.js";
|
|
@@ -26,6 +27,7 @@ export * from "./files.js";
|
|
|
26
27
|
export * from "./listener.js";
|
|
27
28
|
export * from "./processes.js";
|
|
28
29
|
export * from "./settings.js";
|
|
30
|
+
export * from "./side-views.js";
|
|
29
31
|
export * from "./tools.js";
|
|
30
32
|
export * from "./viewers.js";
|
|
31
33
|
export * from "./views.js";
|
|
@@ -38,6 +40,7 @@ export const CONTRIBUTION_POINTS = [
|
|
|
38
40
|
filesPoint,
|
|
39
41
|
viewersPoint,
|
|
40
42
|
documentsPoint,
|
|
43
|
+
sideViewsPoint,
|
|
41
44
|
commandsPoint,
|
|
42
45
|
settingsPoint,
|
|
43
46
|
processesPoint,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { ContributionPoint } from "../contribution-point.js";
|
|
3
|
+
|
|
4
|
+
// A side view the extension may register at runtime (api.sideViews.register): something it can show in the editor's side
|
|
5
|
+
// panel for one input (a CI run, a workflow run), beside whichever section the reader is in, and move into its own view
|
|
6
|
+
// on request. The host owns the panel and the tab; the extension answers what the tab says and draws its body.
|
|
7
|
+
export const SideViewContributionSchema = z.object({
|
|
8
|
+
id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
9
|
+
label: z
|
|
10
|
+
.string()
|
|
11
|
+
.min(1)
|
|
12
|
+
.describe(
|
|
13
|
+
"What one of these is called (\"CI run\"), shown in the install dialog and on a tab whose own title could not be read. Each tab's title is the extension's to say for the thing it shows.",
|
|
14
|
+
),
|
|
15
|
+
// Whether this side view may take links the chat renders (SideViewRegistration.claim): a link it recognises opens
|
|
16
|
+
// beside the chat instead of in a new browser tab. Declared, like a badge, because it changes what the reader's
|
|
17
|
+
// click does; absent, the host never asks it.
|
|
18
|
+
links: z
|
|
19
|
+
.boolean()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe(
|
|
22
|
+
"Allow this side view to take links the chat renders: a link it recognises (its registration's `claim`) opens beside the chat instead of in a new browser tab. Declared because it changes what the reader's click does; leave it out and the host never asks.",
|
|
23
|
+
)
|
|
24
|
+
.meta({ power: { key: "side-view-links:${id}", sentence: 'opens links it recognises as "${label}" beside the chat' } }),
|
|
25
|
+
})
|
|
26
|
+
.meta({ power: { key: "side-view:${id}", sentence: 'shows "${label}" in the side panel' } });
|
|
27
|
+
export type SideViewContribution = z.infer<typeof SideViewContributionSchema>;
|
|
28
|
+
|
|
29
|
+
export const sideViewsPoint = {
|
|
30
|
+
name: "sideViews",
|
|
31
|
+
description:
|
|
32
|
+
"Things this extension can show in the editor's side panel, one input at a time, beside whatever the reader is doing. Each entry reserves an id; the extension supplies the component with api.sideViews.register and opens one with api.sideViews.open, and the host refuses any id this list does not cover.",
|
|
33
|
+
schema: z.array(SideViewContributionSchema),
|
|
34
|
+
} as const satisfies ContributionPoint;
|
package/src/points/viewers.ts
CHANGED
|
@@ -21,7 +21,7 @@ export const ViewerContributionSchema = z.object({
|
|
|
21
21
|
fetch: z
|
|
22
22
|
.enum(["text", "blob", "url", "path"])
|
|
23
23
|
.describe(
|
|
24
|
-
"How much of the file the host hands you. `text` for a format that is text (svg, a subtitle track). `blob` for one that must be parsed end to end before any of it shows (a .docx, a spreadsheet), bounded by the daemon's raw-read cap. `url` for anything range-read rather than parsed (audio, video): your component gets a streaming URL to point an element at, never the bytes. `path` for a viewer whose own backend reads and writes the file: you get the workspace path and the scope it is viewed in,
|
|
24
|
+
"How much of the file the host hands you. `text` for a format that is text (svg, a subtitle track). `blob` for one that must be parsed end to end before any of it shows (a .docx, a spreadsheet), bounded by the daemon's raw-read cap. `url` for anything range-read rather than parsed (audio, video): your component gets a streaming URL to point an element at, never the bytes. `path` for a viewer whose own backend reads and writes the file: you get the workspace path and the scope it is viewed in, plus `readOnly` where the window may not write the file and, in a desktop app's local window, a `text` slot holding the document's text for while your own view can't show it. Emit `dirty` (a boolean) whenever you start or stop holding edits the file doesn't have, so a window closing over them can ask first.",
|
|
25
25
|
),
|
|
26
26
|
// An editing viewer outranks a render-only one claiming the same extension; among equals the later registration
|
|
27
27
|
// wins. Declared, not timed: activation order is not stable across extensions.
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { manifestJsonSchema } from "./json-schema.js";
|
|
3
|
+
import { ExtensionManifestSchema } from "./manifest.js";
|
|
4
|
+
import { powersOf } from "./powers-diff.js";
|
|
5
|
+
|
|
6
|
+
// Every power the schema declares must COUNT: a manifest that sets the field folds to exactly one more power than the
|
|
7
|
+
// same manifest without it, under the key and sentence below. The powers are the owner's approval (a workspace
|
|
8
|
+
// extension's digest is taken over their keys), so one the fold silently drops is a power granted without being asked
|
|
9
|
+
// for. The list of declared powers is read off the published authoring schema, where each field's `.meta({ power })`
|
|
10
|
+
// lands, rather than off the walker under test: a new power with no row here fails, and so does a zod change that
|
|
11
|
+
// hides a field from the walker (a union's internals, a wrapper it does not see through).
|
|
12
|
+
|
|
13
|
+
// What a row sets beyond the identity every manifest carries.
|
|
14
|
+
type Fields = Omit<z.input<typeof ExtensionManifestSchema>, "publisher" | "name" | "version" | "engines">;
|
|
15
|
+
type Contributes = NonNullable<Fields["contributes"]>;
|
|
16
|
+
type Card = NonNullable<Contributes["capabilities"]>[number];
|
|
17
|
+
|
|
18
|
+
const manifest = (fields: Fields): ReturnType<typeof ExtensionManifestSchema.parse> =>
|
|
19
|
+
ExtensionManifestSchema.parse({ publisher: "acme", name: "demo", version: "1.0.0", engines: { intentic: "^1.0.0" }, ...fields });
|
|
20
|
+
|
|
21
|
+
const contributes = (points: Contributes, fields: Fields = {}): Fields => ({ ...fields, contributes: points });
|
|
22
|
+
|
|
23
|
+
const CATALOG = { name: "Acme", description: "One line.", category: "code" };
|
|
24
|
+
const CLI_CARD = {
|
|
25
|
+
id: "acme-cli",
|
|
26
|
+
kind: "cli",
|
|
27
|
+
catalog: CATALOG,
|
|
28
|
+
fields: [{ key: "token", label: "Token", secret: true }],
|
|
29
|
+
env: { ACME_TOKEN: "${token}" },
|
|
30
|
+
skill: "skills/acme/SKILL.md",
|
|
31
|
+
} satisfies Card;
|
|
32
|
+
const VIEW = { id: "board", label: "Board", surface: "rail" } as const;
|
|
33
|
+
const COMMAND = { command: "acme.run", title: "Run" };
|
|
34
|
+
const SETTING = { key: "token", type: "string", title: "Token" } as const;
|
|
35
|
+
const SERVER = { server: "dist/server.js" };
|
|
36
|
+
// One card of each kind, so every arm of the capability union is reached through it.
|
|
37
|
+
const CARDS: readonly Card[] = [
|
|
38
|
+
CLI_CARD,
|
|
39
|
+
{ id: "acme-web", kind: "browser", catalog: CATALOG, fields: [], loginUrl: "https://acme.test/login", skill: "skills/web.md" },
|
|
40
|
+
{ id: "acme-os", kind: "device", catalog: CATALOG, fields: [], skill: "skills/os.md" },
|
|
41
|
+
{ id: "acme-ext", kind: "webext", catalog: CATALOG, fields: [], skill: "skills/ext.md" },
|
|
42
|
+
{ id: "acme-agent", kind: "agent", catalog: CATALOG, fields: [] },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
interface Row {
|
|
46
|
+
// The power's key template, as the schema declares it.
|
|
47
|
+
readonly power: string;
|
|
48
|
+
readonly without: Fields;
|
|
49
|
+
readonly with: Fields;
|
|
50
|
+
// The one power `with` adds: its filled key and sentence.
|
|
51
|
+
readonly adds: readonly [string, string];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ROWS: readonly Row[] = [
|
|
55
|
+
{ power: "entry", without: {}, with: { entry: "dist/extension.js" }, adds: ["entry", "runs a UI bundle in your browser"] },
|
|
56
|
+
{ power: "server", without: {}, with: SERVER, adds: ["server", "runs a backend bundle inside the daemon's extension host"] },
|
|
57
|
+
{
|
|
58
|
+
power: "sandbox:${value}",
|
|
59
|
+
without: { permissions: {} },
|
|
60
|
+
with: { permissions: { sandbox: ["POST /panels/*/start"] } },
|
|
61
|
+
adds: ["sandbox:POST /panels/*/start", "its UI calls the sandbox route POST /panels/*/start"],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
power: "daemon:${value}",
|
|
65
|
+
without: { permissions: {} },
|
|
66
|
+
with: { permissions: { daemon: ["GET /workspace/tree"] } },
|
|
67
|
+
adds: ["daemon:GET /workspace/tree", "its backend calls the daemon route GET /workspace/tree"],
|
|
68
|
+
},
|
|
69
|
+
{ power: "view:${id}", without: contributes({ views: [] }), with: contributes({ views: [VIEW] }), adds: ["view:board", 'a rail view "Board"'] },
|
|
70
|
+
{
|
|
71
|
+
power: "view-badge:${id}",
|
|
72
|
+
without: contributes({ views: [{ ...VIEW, badge: false }] }),
|
|
73
|
+
with: contributes({ views: [{ ...VIEW, badge: true }] }),
|
|
74
|
+
adds: ["view-badge:board", 'may badge the "Board" tile from any screen'],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
power: "files:${path}",
|
|
78
|
+
without: contributes({ files: [] }),
|
|
79
|
+
with: contributes({ files: [{ path: ".intentic/config/acme.json", invalidates: ["acme"] }] }),
|
|
80
|
+
adds: ["files:.intentic/config/acme.json", "is told when .intentic/config/acme.json changes"],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
power: "viewer:${id}",
|
|
84
|
+
without: contributes({ viewers: [] }),
|
|
85
|
+
with: contributes({ viewers: [{ id: "office", extensions: ["docx", "xlsx"], fetch: "blob", edit: true, compare: true }] }),
|
|
86
|
+
adds: ["viewer:office", "opens and edits and compares .docx, .xlsx files (blob)"],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
power: "document:${id}",
|
|
90
|
+
without: contributes({ documents: [] }),
|
|
91
|
+
with: contributes({ documents: [{ id: "readmes", label: "Readmes" }] }),
|
|
92
|
+
adds: ["document:readmes", 'marks workspace directories ("Readmes")'],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
power: "side-view:${id}",
|
|
96
|
+
without: contributes({ sideViews: [] }),
|
|
97
|
+
with: contributes({ sideViews: [{ id: "run", label: "CI run" }] }),
|
|
98
|
+
adds: ["side-view:run", 'shows "CI run" in the side panel'],
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
power: "side-view-links:${id}",
|
|
102
|
+
without: contributes({ sideViews: [{ id: "run", label: "CI run", links: false }] }),
|
|
103
|
+
with: contributes({ sideViews: [{ id: "run", label: "CI run", links: true }] }),
|
|
104
|
+
adds: ["side-view-links:run", 'opens links it recognises as "CI run" beside the chat'],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
power: "command:${command}",
|
|
108
|
+
without: contributes({ commands: [] }),
|
|
109
|
+
with: contributes({ commands: [COMMAND] }),
|
|
110
|
+
adds: ["command:acme.run", 'a palette command "Run"'],
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
power: "keybinding:${command}",
|
|
114
|
+
without: contributes({ commands: [COMMAND] }),
|
|
115
|
+
with: contributes({ commands: [{ ...COMMAND, keybinding: "Mod+Shift+K" }] }),
|
|
116
|
+
adds: ["keybinding:acme.run", 'the global shortcut Mod+Shift+K ("Run")'],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
power: "setting-env:${key}",
|
|
120
|
+
without: contributes({ settings: [SETTING] }),
|
|
121
|
+
with: contributes({ settings: [{ ...SETTING, env: "ACME_TOKEN" }] }),
|
|
122
|
+
adds: ["setting-env:token", `puts the "token" setting into the agent's environment as ACME_TOKEN`],
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
power: "process:${name}",
|
|
126
|
+
without: contributes({ processes: [] }),
|
|
127
|
+
with: contributes({ processes: [{ name: "worker", command: "node worker.js", autoStart: true }] }),
|
|
128
|
+
adds: ["process:worker", 'a background process "worker" (starts on boot)'],
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
power: "agent",
|
|
132
|
+
without: contributes({}),
|
|
133
|
+
with: contributes({ agent: {} }),
|
|
134
|
+
adds: ["agent", "contributes skills, agents and hooks to the agent's turns"],
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
power: "environment",
|
|
138
|
+
without: contributes({}),
|
|
139
|
+
with: contributes({ environment: { fragment: "environment/Dockerfile" } }),
|
|
140
|
+
adds: ["environment", "bakes an environment fragment into the sandbox image"],
|
|
141
|
+
},
|
|
142
|
+
...CARDS.map(
|
|
143
|
+
(card): Row => ({
|
|
144
|
+
power: "capability:${id}",
|
|
145
|
+
without: contributes({ capabilities: [] }),
|
|
146
|
+
with: contributes({ capabilities: [card] }),
|
|
147
|
+
adds: [`capability:${card.id}`, `a ${card.kind} capability card "Acme"`],
|
|
148
|
+
}),
|
|
149
|
+
),
|
|
150
|
+
{
|
|
151
|
+
power: "capability-tools:${id}",
|
|
152
|
+
without: contributes({ capabilities: [CLI_CARD] }, SERVER),
|
|
153
|
+
with: contributes({ capabilities: [{ ...CLI_CARD, mcp: "mcp" }] }, SERVER),
|
|
154
|
+
adds: ["capability-tools:acme-cli", 'serves MCP tools to the agent for each "Acme" card'],
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
power: "listener:${provider}",
|
|
158
|
+
without: contributes({}),
|
|
159
|
+
with: contributes({
|
|
160
|
+
listener: {
|
|
161
|
+
provider: "acme",
|
|
162
|
+
events: [{ type: "message", label: "A message" }],
|
|
163
|
+
automation: { label: "Acme", channel: { label: "Channel", placeholder: "#general" }, starterPrompt: "Answer it." },
|
|
164
|
+
},
|
|
165
|
+
}),
|
|
166
|
+
adds: ["listener:acme", 'a realtime listener provider "acme"'],
|
|
167
|
+
},
|
|
168
|
+
{ power: "bin", without: contributes({}), with: contributes({ bin: "bin" }), adds: ["bin", "puts its shipped tools on the agent's PATH"] },
|
|
169
|
+
{
|
|
170
|
+
power: "tools${perCard?-${perCard}:}",
|
|
171
|
+
without: contributes({}, SERVER),
|
|
172
|
+
with: contributes({ tools: {} }, SERVER),
|
|
173
|
+
adds: ["tools", "gives the agent MCP tools"],
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
power: "tools${perCard?-${perCard}:}",
|
|
177
|
+
without: contributes({ capabilities: [CLI_CARD] }, SERVER),
|
|
178
|
+
with: contributes({ capabilities: [CLI_CARD], tools: { perCard: "acme-cli" } }, SERVER),
|
|
179
|
+
adds: ["tools-acme-cli", 'gives the agent MCP tools, one server for each "acme-cli" card'],
|
|
180
|
+
},
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
// Every power key template the authoring schema carries, wherever it sits: the serializer visits every node.
|
|
184
|
+
const PowerNode = z.object({ power: z.object({ key: z.string() }) });
|
|
185
|
+
const declaredPowers = (): Set<string> => {
|
|
186
|
+
const keys = new Set<string>();
|
|
187
|
+
JSON.stringify(manifestJsonSchema(), (_key, node) => {
|
|
188
|
+
const declared = PowerNode.safeParse(node);
|
|
189
|
+
if (declared.success) {
|
|
190
|
+
keys.add(declared.data.power.key);
|
|
191
|
+
}
|
|
192
|
+
return node;
|
|
193
|
+
});
|
|
194
|
+
return keys;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
describe("powersOf", () => {
|
|
198
|
+
test("a manifest declaring nothing consequential folds to no powers", () => {
|
|
199
|
+
expect([...powersOf(manifest({}))]).toEqual([]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("every power the schema declares has a row below, and every row names a declared power", () => {
|
|
203
|
+
expect([...declaredPowers()].toSorted()).toEqual([...new Set(ROWS.map((row) => row.power))].toSorted());
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test.each(ROWS.map((row) => [row.adds[0], row] as const))("%s counts", (_key, row) => {
|
|
207
|
+
const before = powersOf(manifest(row.without));
|
|
208
|
+
const after = powersOf(manifest(row.with));
|
|
209
|
+
expect([...after].filter(([key]) => !before.has(key))).toEqual([[...row.adds]]);
|
|
210
|
+
expect([...before].filter(([key]) => !after.has(key))).toEqual([]);
|
|
211
|
+
});
|
|
212
|
+
});
|