@tapcue/extension-sdk 0.1.2 → 0.2.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/package.json +1 -1
- package/src/generic-keywords.ts +69 -0
- package/src/index.ts +1 -0
- package/src/manifest.ts +323 -14
- package/src/testing/test-host.ts +10 -2
- package/src/types.ts +25 -1
package/package.json
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The words a keyword may not be (spec 028 §1.4).
|
|
3
|
+
*
|
|
4
|
+
* A `keywords` entry buys its command a whole-word match in root search. That is the right trade
|
|
5
|
+
* for `eyedropper` and a disaster for `open`: one extension claiming the ten commonest verbs in
|
|
6
|
+
* five languages would sit in the candidate pool of nearly every query anybody types, and the only
|
|
7
|
+
* thing standing between it and the top of the list would be the ranker's opinion of a tie.
|
|
8
|
+
*
|
|
9
|
+
* So the blacklist is not a style guide, it is the thing that keeps the keyword field worth
|
|
10
|
+
* having. It is deliberately short and deliberately multilingual — an author writing a Chinese
|
|
11
|
+
* extension should hit exactly the same wall as one writing an English one, and a blacklist that
|
|
12
|
+
* only knew English would read as "generic words are fine as long as they are not in English".
|
|
13
|
+
*
|
|
14
|
+
* **What it is not**: a stopword list for the ranker. The ranker sees the user's query and the
|
|
15
|
+
* candidate's real title; this is only about what an author may *add* to that. A command called
|
|
16
|
+
* "Open in Browser" keeps that title, and keeps matching `open`, because its title says so.
|
|
17
|
+
*
|
|
18
|
+
* Matching is on the normalized keyword (lowercased, whitespace-trimmed), whole-string. `opener`
|
|
19
|
+
* and `open file` are not on the list: the entry has to *be* the generic word for the failure mode
|
|
20
|
+
* above to apply.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One family per row, so a reviewer can see what is being refused and why.
|
|
25
|
+
*
|
|
26
|
+
* The families are the ones spec §1.4 names — the verbs and nouns every launcher command shares —
|
|
27
|
+
* in the languages Tapcue itself ships (`locales/`): English, Simplified Chinese, Japanese,
|
|
28
|
+
* German, French, Spanish.
|
|
29
|
+
*/
|
|
30
|
+
const GENERIC_KEYWORD_FAMILIES: Readonly<Record<string, readonly string[]>> = Object.freeze({
|
|
31
|
+
open: ["open", "打开", "开启", "開く", "ひらく", "öffnen", "offnen", "ouvrir", "abrir"],
|
|
32
|
+
file: ["file", "files", "文件", "檔案", "ファイル", "datei", "dateien", "fichier", "fichiers", "archivo", "archivos"],
|
|
33
|
+
search: [
|
|
34
|
+
"search", "find", "查找", "搜索", "搜寻", "検索", "さがす", "suche", "suchen", "finden",
|
|
35
|
+
"rechercher", "recherche", "chercher", "buscar", "búsqueda", "busqueda",
|
|
36
|
+
],
|
|
37
|
+
new: ["new", "create", "新建", "新增", "创建", "新規", "あたらしい", "neu", "erstellen", "nouveau", "nouvelle", "créer", "creer", "nuevo", "nueva", "crear"],
|
|
38
|
+
copy: ["copy", "复制", "拷贝", "コピー", "kopieren", "kopie", "copier", "copiar"],
|
|
39
|
+
paste: ["paste", "粘贴", "貼り付け", "はりつけ", "einfügen", "einfugen", "coller", "pegar"],
|
|
40
|
+
app: ["app", "apps", "application", "applications", "应用", "程序", "アプリ", "アプリケーション", "anwendung", "programm", "aplicación", "aplicacion", "aplicaciones"],
|
|
41
|
+
run: ["run", "execute", "start", "运行", "执行", "启动", "実行", "じっこう", "ausführen", "ausfuhren", "starten", "exécuter", "executer", "lancer", "ejecutar", "iniciar"],
|
|
42
|
+
show: ["show", "view", "display", "显示", "查看", "表示", "ひょうじ", "zeigen", "anzeigen", "afficher", "voir", "mostrar", "ver"],
|
|
43
|
+
go: ["go", "goto", "前往", "跳转", "移動", "いく", "gehe", "gehen", "aller", "ir"],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every blacklisted word, flattened. Exported so the Swift mirror can read one list rather than
|
|
48
|
+
* reconstructing the families, and so a test can assert the two agree.
|
|
49
|
+
*/
|
|
50
|
+
export const GENERIC_KEYWORDS: readonly string[] = Object.freeze(
|
|
51
|
+
[...new Set(Object.values(GENERIC_KEYWORD_FAMILIES).flat())].sort(),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const GENERIC_KEYWORD_SET: ReadonlySet<string> = new Set(GENERIC_KEYWORDS);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Normalizes the way the check compares: case-folded, whitespace-trimmed, inner runs collapsed.
|
|
58
|
+
*
|
|
59
|
+
* Full-width Latin (`open`) folds to ASCII through NFKC, because an author who types a keyword
|
|
60
|
+
* in a CJK input method should not get a different verdict than one who did not.
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeKeyword(value: string): string {
|
|
63
|
+
return value.normalize("NFKC").trim().replace(/\s+/g, " ").toLowerCase();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Whether this keyword is one of the words §1.4 refuses. */
|
|
67
|
+
export function isGenericKeyword(value: string): boolean {
|
|
68
|
+
return GENERIC_KEYWORD_SET.has(normalizeKeyword(value));
|
|
69
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from "./capabilities.js";
|
|
|
6
6
|
export * from "./types.js";
|
|
7
7
|
export * from "./define-extension.js";
|
|
8
8
|
export * from "./manifest.js";
|
|
9
|
+
export * from "./generic-keywords.js";
|
|
9
10
|
export * from "./permission-units.js";
|
|
10
11
|
export * from "./view.js";
|
|
11
12
|
export * from "./reactive.js";
|
package/src/manifest.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
CONTRIBUTION_MATCH_FIELDS,
|
|
14
14
|
isContributableScopeType,
|
|
15
15
|
} from "./types.js";
|
|
16
|
+
import { isGenericKeyword } from "./generic-keywords.js";
|
|
16
17
|
import type { ContributableScopeType } from "./types.js";
|
|
17
18
|
import {
|
|
18
19
|
BOOLEAN_PERMISSION_GROUPS,
|
|
@@ -77,7 +78,21 @@ export interface ExtensionManifest {
|
|
|
77
78
|
* Absent or empty for almost every extension. The forms above simply become undeclarable.
|
|
78
79
|
*/
|
|
79
80
|
integratesWith?: string[];
|
|
80
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Required for every package type but `skill`, which has none: a skill is markdown, and its
|
|
83
|
+
* surface is `documents` (spec 028 §1.3).
|
|
84
|
+
*/
|
|
85
|
+
commands?: ManifestCommand[];
|
|
86
|
+
/**
|
|
87
|
+
* **The markdown a `skill` package carries** (spec 028 §1.3) — and the only thing it carries.
|
|
88
|
+
*
|
|
89
|
+
* A skill has no commands and no code. What it offers is documents that get handed to an
|
|
90
|
+
* assistant surface as context, so the manifest names them the way it names commands: declared,
|
|
91
|
+
* readable without unpacking anything, and localizable. The *bodies* stay in the package —
|
|
92
|
+
* the catalog index publishes only these titles, because a title is what a row shows and a body
|
|
93
|
+
* is something you get after you install it.
|
|
94
|
+
*/
|
|
95
|
+
documents?: ManifestDocument[];
|
|
81
96
|
/**
|
|
82
97
|
* **Rows this extension adds to scopes it does not own** (architecture §11.3) — the third
|
|
83
98
|
* right over a scope, next to owning one and producing an item that enters one.
|
|
@@ -142,6 +157,18 @@ export interface ExtensionManifest {
|
|
|
142
157
|
optionalPermissions?: ManifestPermissions;
|
|
143
158
|
}
|
|
144
159
|
|
|
160
|
+
/** One markdown document inside a `skill` package. */
|
|
161
|
+
export interface ManifestDocument {
|
|
162
|
+
/**
|
|
163
|
+
* Package-relative path to a `.md` file that is actually in the archive. Relative, no traversal,
|
|
164
|
+
* and `.md` — the same three rules an `asset:` reference lives by, for the same reason: this
|
|
165
|
+
* string is resolved against a directory on the user's disk.
|
|
166
|
+
*/
|
|
167
|
+
path: string;
|
|
168
|
+
/** What the document is called. Localizable (`@key`), and the only part the index publishes. */
|
|
169
|
+
title: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
145
172
|
/**
|
|
146
173
|
* One contribution: a native scope, which of its subjects, and the rows to add there.
|
|
147
174
|
*
|
|
@@ -565,6 +592,16 @@ export interface ManifestPermissions {
|
|
|
565
592
|
export interface ManifestProblem {
|
|
566
593
|
path: string;
|
|
567
594
|
message: string;
|
|
595
|
+
/**
|
|
596
|
+
* A stable rule id, for the problems the Swift mirror is measured against by name rather than
|
|
597
|
+
* by message (`@tapcue/extension-cli`'s `Problem.rule`, spec 028 §1.4).
|
|
598
|
+
*
|
|
599
|
+
* Absent on most problems, and that is not an oversight: the CLI reports an unnamed manifest
|
|
600
|
+
* problem as the generic `manifest.field`, which is all a corpus needs for a rule whose fixture
|
|
601
|
+
* nobody writes. A rule gets a name here when a fixture in `fixtures/malicious.ts` names it,
|
|
602
|
+
* because that name is the whole contract between the two implementations.
|
|
603
|
+
*/
|
|
604
|
+
rule?: string;
|
|
568
605
|
}
|
|
569
606
|
|
|
570
607
|
/** Same grammar the shell resolves at runtime; see `scene.ts`. */
|
|
@@ -623,6 +660,9 @@ const COMMAND_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
|
623
660
|
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
|
|
624
661
|
const HOST_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
|
625
662
|
|
|
663
|
+
/** How every check below says no. `rule` is set only where a fixture names one; see `ManifestProblem`. */
|
|
664
|
+
type Fail = (path: string, message: string, rule?: string) => void;
|
|
665
|
+
|
|
626
666
|
/**
|
|
627
667
|
* Validates a parsed manifest without executing any package code. Returns every
|
|
628
668
|
* problem it finds rather than throwing on the first, so an author sees the full
|
|
@@ -630,7 +670,8 @@ const HOST_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-
|
|
|
630
670
|
*/
|
|
631
671
|
export function validateManifest(value: unknown): ManifestProblem[] {
|
|
632
672
|
const problems: ManifestProblem[] = [];
|
|
633
|
-
const fail = (path
|
|
673
|
+
const fail: Fail = (path, message, rule) =>
|
|
674
|
+
problems.push(rule === undefined ? { path, message } : { path, message, rule });
|
|
634
675
|
|
|
635
676
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
636
677
|
return [{ path: "", message: "manifest must be a JSON object" }];
|
|
@@ -676,9 +717,17 @@ export function validateManifest(value: unknown): ManifestProblem[] {
|
|
|
676
717
|
}
|
|
677
718
|
|
|
678
719
|
const commands = m.commands;
|
|
679
|
-
|
|
720
|
+
// A skill is markdown: it has no commands, and §1.3 says so in as many words. Demanding one
|
|
721
|
+
// would make the whole package type undeclarable, so the requirement is per type — and
|
|
722
|
+
// `documents` takes its place, checked in `validateDocuments`.
|
|
723
|
+
if (m.type === "skill") {
|
|
724
|
+
if (commands !== undefined) {
|
|
725
|
+
fail("commands", "a skill package has no commands — its surface is documents");
|
|
726
|
+
}
|
|
727
|
+
} else if (!Array.isArray(commands) || commands.length === 0) {
|
|
680
728
|
fail("commands", "must declare at least one command");
|
|
681
|
-
}
|
|
729
|
+
}
|
|
730
|
+
if (Array.isArray(commands)) {
|
|
682
731
|
const seen = new Set<string>();
|
|
683
732
|
commands.forEach((raw, index) => {
|
|
684
733
|
const path = `commands[${index}]`;
|
|
@@ -715,6 +764,7 @@ export function validateManifest(value: unknown): ManifestProblem[] {
|
|
|
715
764
|
fail(`${path}.activation.runsAtRoot`, "only a remote command can run at root");
|
|
716
765
|
}
|
|
717
766
|
validateInput(c.input as CommandInput | undefined, `${path}.input`, fail);
|
|
767
|
+
validateKeywords(c.keywords, `${path}.keywords`, fail);
|
|
718
768
|
const surface = c.surface as CommandSurface | undefined;
|
|
719
769
|
if (surface !== undefined) {
|
|
720
770
|
if (surface.layout !== undefined && surface.layout !== "list" && surface.layout !== "grid") {
|
|
@@ -759,6 +809,7 @@ export function validateManifest(value: unknown): ManifestProblem[] {
|
|
|
759
809
|
});
|
|
760
810
|
}
|
|
761
811
|
|
|
812
|
+
validateDocuments(m, fail);
|
|
762
813
|
const integrates = validateIntegratesWith(m, fail);
|
|
763
814
|
validateContributes(m, integrates, fail);
|
|
764
815
|
validateProvides(m, fail);
|
|
@@ -901,9 +952,258 @@ export function effectiveGrants(
|
|
|
901
952
|
|
|
902
953
|
type PermissionRecord = Record<string, any>;
|
|
903
954
|
|
|
955
|
+
/** At most this many `keywords` on one command — see `validateKeywords`. */
|
|
956
|
+
export const MAX_COMMAND_KEYWORDS = 10;
|
|
957
|
+
/** The longest one keyword may be. */
|
|
958
|
+
export const MAX_KEYWORD_LENGTH = 32;
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* `keywords` — how many, how long, and which words are not keywords at all (spec 028 §1.4).
|
|
962
|
+
*
|
|
963
|
+
* A keyword is a claim on the user's typing: it puts this command in the candidate pool for a
|
|
964
|
+
* query that never mentions it by name. Three limits keep that claim proportionate.
|
|
965
|
+
*
|
|
966
|
+
* **Count** and **length** are about the index, which now holds every published extension rather
|
|
967
|
+
* than the handful someone installed: a hundred keywords per command is a way to be in every pool,
|
|
968
|
+
* and it costs the author nothing to write.
|
|
969
|
+
*
|
|
970
|
+
* **The blacklist** is about the pool itself (`generic-keywords.ts`). `open` belongs to every
|
|
971
|
+
* command there has ever been, so a command that claims it is not saying what it does — it is
|
|
972
|
+
* saying "consider me always", and the only thing left deciding is a tie-break.
|
|
973
|
+
*/
|
|
974
|
+
function validateKeywords(value: unknown, path: string, fail: Fail): void {
|
|
975
|
+
if (value === undefined) return;
|
|
976
|
+
if (!Array.isArray(value)) {
|
|
977
|
+
fail(path, "must be an array of keywords");
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
if (value.length > MAX_COMMAND_KEYWORDS) {
|
|
981
|
+
fail(
|
|
982
|
+
path,
|
|
983
|
+
`at most ${MAX_COMMAND_KEYWORDS} keywords — past that a command is not describing itself, it is claiming the query field`,
|
|
984
|
+
"manifest.keywords-count",
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
value.forEach((keyword, index) => {
|
|
988
|
+
const where = `${path}[${index}]`;
|
|
989
|
+
if (typeof keyword !== "string" || keyword.trim() === "") {
|
|
990
|
+
fail(where, "must be a non-empty string");
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
if (keyword.length > MAX_KEYWORD_LENGTH) {
|
|
994
|
+
fail(where, `at most ${MAX_KEYWORD_LENGTH} characters`, "manifest.keywords-length");
|
|
995
|
+
}
|
|
996
|
+
if (isGenericKeyword(keyword)) {
|
|
997
|
+
fail(
|
|
998
|
+
where,
|
|
999
|
+
`"${keyword}" is a word nearly every command shares — a keyword has to narrow the pool, not join it`,
|
|
1000
|
+
"manifest.keywords-generic",
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
|
|
904
1006
|
/** The longest `pattern` a command may declare — see `CommandInput.pattern`. */
|
|
905
1007
|
const MAX_INPUT_PATTERN_LENGTH = 200;
|
|
906
1008
|
|
|
1009
|
+
/**
|
|
1010
|
+
* Constructs a `pattern` may not use, because it runs in the launcher's process (spec 028 §1.4).
|
|
1011
|
+
*
|
|
1012
|
+
* The three refused families are the ones that turn a regular expression into a computation:
|
|
1013
|
+
* **nested quantifiers** (`(a+)+`) are the classic exponential blow-up, **backreferences** make the
|
|
1014
|
+
* language non-regular and the engine a backtracker, and **lookaround** re-runs a subpattern at
|
|
1015
|
+
* every position. None of the three is needed to describe an issue key or a tracking number, which
|
|
1016
|
+
* is what this field is for.
|
|
1017
|
+
*
|
|
1018
|
+
* **This check is deliberately syntactic, and it is not a ReDoS oracle.** It reads the pattern as
|
|
1019
|
+
* text — tracking escapes, character classes, and group nesting — and refuses the shapes above. It
|
|
1020
|
+
* does *not* find every pattern that can backtrack badly: `(a|a)+` and `(a|ab)*` are quadratic or
|
|
1021
|
+
* worse and pass this, as does any blow-up that needs alternation to see. What closes that gap is
|
|
1022
|
+
* elsewhere and is not a parser: the host caps the pattern's length (200), caps the length of the
|
|
1023
|
+
* text it is run against, and runs it off the keystroke path. This function removes the shapes an
|
|
1024
|
+
* attacker would reach for first; the caps are what make the rest uninteresting.
|
|
1025
|
+
*/
|
|
1026
|
+
export function unsafePatternProblem(pattern: string): string | undefined {
|
|
1027
|
+
/** Whether each still-open group has a quantifier anywhere inside it. */
|
|
1028
|
+
const open: { quantified: boolean }[] = [];
|
|
1029
|
+
const markQuantified = () => {
|
|
1030
|
+
for (const group of open) group.quantified = true;
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
let inClass = false;
|
|
1034
|
+
for (let i = 0; i < pattern.length; i += 1) {
|
|
1035
|
+
const char = pattern[i]!;
|
|
1036
|
+
if (char === "\\") {
|
|
1037
|
+
const next = pattern[i + 1];
|
|
1038
|
+
if (!inClass && next !== undefined && /[1-9]/.test(next)) {
|
|
1039
|
+
return "must not use a backreference — it makes the match a backtracking search";
|
|
1040
|
+
}
|
|
1041
|
+
if (!inClass && next === "k" && pattern[i + 2] === "<") {
|
|
1042
|
+
return "must not use a backreference — it makes the match a backtracking search";
|
|
1043
|
+
}
|
|
1044
|
+
i += 1; // the escaped character is never punctuation
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
if (inClass) {
|
|
1048
|
+
if (char === "]") inClass = false;
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
if (char === "[") {
|
|
1052
|
+
inClass = true;
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
if (char === "(") {
|
|
1056
|
+
if (pattern.startsWith("(?=", i) || pattern.startsWith("(?!", i)) {
|
|
1057
|
+
return "must not use lookahead — it re-runs a subpattern at every position";
|
|
1058
|
+
}
|
|
1059
|
+
if (pattern.startsWith("(?<=", i) || pattern.startsWith("(?<!", i)) {
|
|
1060
|
+
return "must not use lookbehind — it re-runs a subpattern at every position";
|
|
1061
|
+
}
|
|
1062
|
+
open.push({ quantified: false });
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
if (char === ")") {
|
|
1066
|
+
const group = open.pop();
|
|
1067
|
+
const quantifier = quantifierAt(pattern, i + 1);
|
|
1068
|
+
if (quantifier === undefined) continue;
|
|
1069
|
+
if (group?.quantified && quantifier.repeats) {
|
|
1070
|
+
return "must not nest quantifiers, e.g. (a+)+ — that is the shape that backtracks exponentially";
|
|
1071
|
+
}
|
|
1072
|
+
markQuantified();
|
|
1073
|
+
i = quantifier.end - 1;
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
const quantifier = quantifierAt(pattern, i);
|
|
1077
|
+
if (quantifier !== undefined) {
|
|
1078
|
+
markQuantified();
|
|
1079
|
+
i = quantifier.end - 1;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return undefined;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* The quantifier starting at `index`, if there is one.
|
|
1087
|
+
*
|
|
1088
|
+
* `repeats` is what separates the dangerous outer quantifier from the harmless one: `(a+)?` may
|
|
1089
|
+
* match its group at most once, so nothing inside it can be re-tried against a different split.
|
|
1090
|
+
* `*`, `+`, `{n,}` and `{n,m}` with m ≥ 2 all can.
|
|
1091
|
+
*/
|
|
1092
|
+
function quantifierAt(
|
|
1093
|
+
pattern: string,
|
|
1094
|
+
index: number,
|
|
1095
|
+
): { end: number; repeats: boolean } | undefined {
|
|
1096
|
+
const char = pattern[index];
|
|
1097
|
+
if (char === "*" || char === "+") return { end: index + 1, repeats: true };
|
|
1098
|
+
if (char === "?") return { end: index + 1, repeats: false };
|
|
1099
|
+
if (char !== "{") return undefined;
|
|
1100
|
+
const close = pattern.indexOf("}", index);
|
|
1101
|
+
if (close < 0) return undefined; // a literal brace, which JavaScript tolerates
|
|
1102
|
+
const body = pattern.slice(index + 1, close);
|
|
1103
|
+
const match = /^(\d+)(,(\d*))?$/.exec(body);
|
|
1104
|
+
if (!match) return undefined;
|
|
1105
|
+
const min = Number(match[1]);
|
|
1106
|
+
const max = match[2] === undefined ? min : match[3] === "" ? Infinity : Number(match[3]);
|
|
1107
|
+
return { end: close + 1, repeats: max >= 2 };
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** The longest a `contributes[].when.host` value may be — see `hostPatternProblem`. */
|
|
1111
|
+
export const MAX_CONTRIBUTION_HOST_LENGTH = 64;
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* A `when.host` value: a host name, lowercase, optionally prefixed `*.` for its subdomains
|
|
1115
|
+
* (spec 028 §1.4).
|
|
1116
|
+
*
|
|
1117
|
+
* Every rule here is about the same thing — a host predicate is a *disclosure*, and the install
|
|
1118
|
+
* sheet reads it out. `*.github.com` says "the rows appear on GitHub", which somebody can decide
|
|
1119
|
+
* about. `*.com` says nothing at all while matching most of the web, so a wildcard needs a label
|
|
1120
|
+
* of its own below the suffix. Uppercase is refused rather than folded so that the string in the
|
|
1121
|
+
* manifest and the string on the sheet are the same string.
|
|
1122
|
+
*/
|
|
1123
|
+
export function hostPatternProblem(value: string): string | undefined {
|
|
1124
|
+
if (value.length > MAX_CONTRIBUTION_HOST_LENGTH) {
|
|
1125
|
+
return `at most ${MAX_CONTRIBUTION_HOST_LENGTH} characters`;
|
|
1126
|
+
}
|
|
1127
|
+
const wildcard = value.startsWith("*.");
|
|
1128
|
+
const host = wildcard ? value.slice(2) : value;
|
|
1129
|
+
if (!/^[a-z0-9.-]+$/.test(host)) {
|
|
1130
|
+
return 'must be a lowercase host name — letters, digits, dots and hyphens, with an optional leading "*."';
|
|
1131
|
+
}
|
|
1132
|
+
const labels = host.split(".");
|
|
1133
|
+
for (const label of labels) {
|
|
1134
|
+
if (label === "") return "has an empty label — no leading, trailing or doubled dots";
|
|
1135
|
+
if (label.startsWith("-") || label.endsWith("-")) return `label "${label}" may not start or end with a hyphen`;
|
|
1136
|
+
}
|
|
1137
|
+
if (labels.length < 2) {
|
|
1138
|
+
// The known limit: this refuses `*.com`, not `*.co.uk`. Telling those apart needs the public
|
|
1139
|
+
// suffix list, which is a megabyte that expires — and the second door here is a human reading
|
|
1140
|
+
// a pull request, who can see a wildcard on a registry suffix for what it is.
|
|
1141
|
+
return wildcard
|
|
1142
|
+
? `"${value}" is every site under a top-level domain — name the site, e.g. *.github.com`
|
|
1143
|
+
: "must be a full host name, e.g. github.com";
|
|
1144
|
+
}
|
|
1145
|
+
return undefined;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Whether a `when.host` value admits a subject's host.
|
|
1150
|
+
*
|
|
1151
|
+
* Whole-string and case-insensitive, like every other match field, with one addition: a leading
|
|
1152
|
+
* `*.` matches any *subdomain* and not the domain itself. `*.github.com` matches
|
|
1153
|
+
* `gist.github.com` and not `github.com` — a contribution that wants both writes both, because
|
|
1154
|
+
* the two are different claims and guessing which one an author meant is how a predicate quietly
|
|
1155
|
+
* widens.
|
|
1156
|
+
*/
|
|
1157
|
+
export function hostMatches(pattern: string, host: string): boolean {
|
|
1158
|
+
const left = pattern.toLowerCase();
|
|
1159
|
+
const right = host.toLowerCase();
|
|
1160
|
+
if (!left.startsWith("*.")) return left === right;
|
|
1161
|
+
const suffix = left.slice(1); // ".github.com"
|
|
1162
|
+
return right.length > suffix.length && right.endsWith(suffix);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* `documents` — the markdown a `skill` package carries, and nothing else may (spec 028 §1.3).
|
|
1167
|
+
*
|
|
1168
|
+
* The path rules are `asset:`'s: relative, no traversal, and here also `.md`, because these paths
|
|
1169
|
+
* are joined onto a directory in the user's store and then read. The title is checked for being
|
|
1170
|
+
* present rather than for being resolvable — a `@key` is resolved against the package's catalogs,
|
|
1171
|
+
* which is a question only the archive can answer (`manifest-check.ts`).
|
|
1172
|
+
*/
|
|
1173
|
+
function validateDocuments(m: Record<string, unknown>, fail: Fail): void {
|
|
1174
|
+
const documents = m.documents;
|
|
1175
|
+
if (m.type !== "skill") {
|
|
1176
|
+
if (documents !== undefined) {
|
|
1177
|
+
fail("documents", 'only a "skill" package carries documents');
|
|
1178
|
+
}
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
if (!Array.isArray(documents) || documents.length === 0) {
|
|
1182
|
+
fail("documents", "a skill package must declare at least one markdown document");
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
const seen = new Set<string>();
|
|
1186
|
+
documents.forEach((raw, index) => {
|
|
1187
|
+
const path = `documents[${index}]`;
|
|
1188
|
+
const document = raw as Record<string, unknown>;
|
|
1189
|
+
const file = document?.path;
|
|
1190
|
+
if (typeof file !== "string" || file.trim() === "") {
|
|
1191
|
+
fail(`${path}.path`, "must name a markdown file inside the package");
|
|
1192
|
+
} else if (file.startsWith("/") || file.includes("\\") || file.split("/").includes("..")) {
|
|
1193
|
+
fail(`${path}.path`, "must be package-relative and must not traverse");
|
|
1194
|
+
} else if (!file.toLowerCase().endsWith(".md")) {
|
|
1195
|
+
fail(`${path}.path`, "must be a .md file — a skill is markdown");
|
|
1196
|
+
} else if (seen.has(file)) {
|
|
1197
|
+
fail(`${path}.path`, `duplicate document "${file}"`);
|
|
1198
|
+
} else {
|
|
1199
|
+
seen.add(file);
|
|
1200
|
+
}
|
|
1201
|
+
if (typeof document?.title !== "string" || document.title.trim() === "") {
|
|
1202
|
+
fail(`${path}.title`, "must say what the document is, for the row and the review sheet");
|
|
1203
|
+
}
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
|
|
907
1207
|
/**
|
|
908
1208
|
* Validates a command's `input` block at install time, where a bad one costs nothing.
|
|
909
1209
|
*
|
|
@@ -916,7 +1216,7 @@ const MAX_INPUT_PATTERN_LENGTH = 200;
|
|
|
916
1216
|
function validateInput(
|
|
917
1217
|
input: CommandInput | undefined,
|
|
918
1218
|
path: string,
|
|
919
|
-
fail:
|
|
1219
|
+
fail: Fail,
|
|
920
1220
|
): void {
|
|
921
1221
|
if (input === undefined) return;
|
|
922
1222
|
if (typeof input !== "object" || input === null) {
|
|
@@ -941,6 +1241,8 @@ function validateInput(
|
|
|
941
1241
|
} else {
|
|
942
1242
|
try {
|
|
943
1243
|
new RegExp(input.pattern);
|
|
1244
|
+
const unsafe = unsafePatternProblem(input.pattern);
|
|
1245
|
+
if (unsafe) fail(`${path}.pattern`, unsafe, "manifest.pattern-unsafe");
|
|
944
1246
|
} catch {
|
|
945
1247
|
fail(`${path}.pattern`, "is not a valid regular expression");
|
|
946
1248
|
}
|
|
@@ -1083,7 +1385,10 @@ export function manifestCatalogKeys(manifest: ExtensionManifest): string[] {
|
|
|
1083
1385
|
};
|
|
1084
1386
|
take(manifest.name);
|
|
1085
1387
|
take(manifest.description);
|
|
1086
|
-
for (const
|
|
1388
|
+
for (const document of manifest.documents ?? []) {
|
|
1389
|
+
take(document.title);
|
|
1390
|
+
}
|
|
1391
|
+
for (const command of manifest.commands ?? []) {
|
|
1087
1392
|
take(command.title);
|
|
1088
1393
|
take(command.description);
|
|
1089
1394
|
for (const argument of command.arguments ?? []) {
|
|
@@ -1175,7 +1480,7 @@ export function scopeTypePrefix(extensionId: string): string {
|
|
|
1175
1480
|
*/
|
|
1176
1481
|
function validateProvides(
|
|
1177
1482
|
m: Record<string, unknown>,
|
|
1178
|
-
fail:
|
|
1483
|
+
fail: Fail,
|
|
1179
1484
|
): void {
|
|
1180
1485
|
const declaresWorkspaceRoots = (block: unknown): boolean =>
|
|
1181
1486
|
(block as ManifestPermissions | undefined)?.workspace?.roots === true;
|
|
@@ -1224,7 +1529,7 @@ function validateProvides(
|
|
|
1224
1529
|
function validateContributes(
|
|
1225
1530
|
m: Record<string, unknown>,
|
|
1226
1531
|
integrates: string[],
|
|
1227
|
-
fail:
|
|
1532
|
+
fail: Fail,
|
|
1228
1533
|
): void {
|
|
1229
1534
|
const contributes = m.contributes;
|
|
1230
1535
|
if (contributes === undefined) return;
|
|
@@ -1297,6 +1602,10 @@ function validateContributes(
|
|
|
1297
1602
|
`"${value}" is not in integratesWith — declare the apps this extension works with there`,
|
|
1298
1603
|
);
|
|
1299
1604
|
}
|
|
1605
|
+
if (field === "host") {
|
|
1606
|
+
const problem = hostPatternProblem(value);
|
|
1607
|
+
if (problem) fail(`${fieldPath}[${valueIndex}]`, problem, "manifest.host-invalid");
|
|
1608
|
+
}
|
|
1300
1609
|
});
|
|
1301
1610
|
}
|
|
1302
1611
|
});
|
|
@@ -1316,7 +1625,7 @@ const MAX_SUGGESTION_RULES_LENGTH = 8_000;
|
|
|
1316
1625
|
*/
|
|
1317
1626
|
function validateSuggestionRules(
|
|
1318
1627
|
value: unknown,
|
|
1319
|
-
fail:
|
|
1628
|
+
fail: Fail,
|
|
1320
1629
|
): void {
|
|
1321
1630
|
if (value === undefined) return;
|
|
1322
1631
|
let text: string;
|
|
@@ -1342,7 +1651,7 @@ function validateSuggestionRules(
|
|
|
1342
1651
|
/** `integratesWith` — bundle identifiers, unique, and the anchor every other reach resolves against. */
|
|
1343
1652
|
function validateIntegratesWith(
|
|
1344
1653
|
m: Record<string, unknown>,
|
|
1345
|
-
fail:
|
|
1654
|
+
fail: Fail,
|
|
1346
1655
|
): string[] {
|
|
1347
1656
|
const raw = m.integratesWith;
|
|
1348
1657
|
if (raw === undefined) return [];
|
|
@@ -1382,7 +1691,7 @@ function validateRootPath(
|
|
|
1382
1691
|
root: FileRoot,
|
|
1383
1692
|
value: unknown,
|
|
1384
1693
|
path: string,
|
|
1385
|
-
fail:
|
|
1694
|
+
fail: Fail,
|
|
1386
1695
|
): void {
|
|
1387
1696
|
if (ROOT_PATH_SHAPES[root] === "dot-segment") {
|
|
1388
1697
|
if (typeof value !== "string" || value.trim() === "") {
|
|
@@ -1416,7 +1725,7 @@ function validateRootPath(
|
|
|
1416
1725
|
function validateReadOnlySql(
|
|
1417
1726
|
value: unknown,
|
|
1418
1727
|
path: string,
|
|
1419
|
-
fail:
|
|
1728
|
+
fail: Fail,
|
|
1420
1729
|
): void {
|
|
1421
1730
|
if (typeof value !== "string" || value.trim() === "") {
|
|
1422
1731
|
fail(path, "must be a SELECT statement");
|
|
@@ -1442,7 +1751,7 @@ function validateReach(
|
|
|
1442
1751
|
permissions: ManifestPermissions | undefined,
|
|
1443
1752
|
block: string,
|
|
1444
1753
|
integrates: string[],
|
|
1445
|
-
fail:
|
|
1754
|
+
fail: Fail,
|
|
1446
1755
|
): void {
|
|
1447
1756
|
if (!permissions) return;
|
|
1448
1757
|
|
|
@@ -1559,7 +1868,7 @@ function validateReach(
|
|
|
1559
1868
|
}
|
|
1560
1869
|
|
|
1561
1870
|
/** A subpath inside a host-chosen root: no wildcards, no climbing, no absolute form. */
|
|
1562
|
-
function validateSubpath(value: string, path: string, fail:
|
|
1871
|
+
function validateSubpath(value: string, path: string, fail: Fail): void {
|
|
1563
1872
|
if (value.trim() === "") {
|
|
1564
1873
|
fail(path, "must be a non-empty subpath");
|
|
1565
1874
|
} else if (value.startsWith("/") || value.startsWith("~")) {
|
package/src/testing/test-host.ts
CHANGED
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
type ManifestPermissions,
|
|
79
79
|
coerceSetting,
|
|
80
80
|
effectiveGrants,
|
|
81
|
+
hostMatches,
|
|
81
82
|
iconProblem,
|
|
82
83
|
namespacedItemId,
|
|
83
84
|
permissionUnits,
|
|
@@ -122,6 +123,7 @@ const NATIVE_SUBJECT_FIELDS: Readonly<Record<ContributableScopeType, readonly st
|
|
|
122
123
|
application: ["bundleId", "name"],
|
|
123
124
|
file: ["path", "name", "extension", "isDirectory"],
|
|
124
125
|
"file.folder": ["path", "name", "extension", "isDirectory"],
|
|
126
|
+
url: ["url", "host"],
|
|
125
127
|
});
|
|
126
128
|
|
|
127
129
|
export interface HostLimits {
|
|
@@ -1368,7 +1370,7 @@ export function createTestHost(options: TestHostOptions): TestHost {
|
|
|
1368
1370
|
}
|
|
1369
1371
|
|
|
1370
1372
|
function commandOf(commandId: string): ManifestCommand {
|
|
1371
|
-
const command = manifest.commands.find((c) => c.id === commandId);
|
|
1373
|
+
const command = (manifest.commands ?? []).find((c) => c.id === commandId);
|
|
1372
1374
|
if (!command) throw new Error(`command "${commandId}" is not declared in the manifest`);
|
|
1373
1375
|
return command;
|
|
1374
1376
|
}
|
|
@@ -1701,6 +1703,12 @@ export function createTestHost(options: TestHostOptions): TestHost {
|
|
|
1701
1703
|
if (!matchable.includes(field)) return false;
|
|
1702
1704
|
const actual = subject[field];
|
|
1703
1705
|
if (typeof actual !== "string") return false;
|
|
1706
|
+
// `host` is the one field with a wildcard form, and `hostMatches` is the only place it is
|
|
1707
|
+
// read — the shell and this fake must agree that `*.github.com` is not `github.com`.
|
|
1708
|
+
if (field === "host") {
|
|
1709
|
+
if (!values.some((value) => hostMatches(value, actual))) return false;
|
|
1710
|
+
continue;
|
|
1711
|
+
}
|
|
1704
1712
|
const lowered = actual.toLowerCase();
|
|
1705
1713
|
if (!values.some((value) => value.toLowerCase() === lowered)) return false;
|
|
1706
1714
|
}
|
|
@@ -1913,7 +1921,7 @@ export function createTestHost(options: TestHostOptions): TestHost {
|
|
|
1913
1921
|
// The command whose query produced this item — not `commands[0]`. An action
|
|
1914
1922
|
// inside the scope is still routed to (extension, command, item, action), and
|
|
1915
1923
|
// an extension with two commands would otherwise be told the wrong one.
|
|
1916
|
-
const commandId = itemOrigin.get(item.id) ?? manifest.commands[0]
|
|
1924
|
+
const commandId = itemOrigin.get(item.id) ?? manifest.commands?.[0]?.id ?? "";
|
|
1917
1925
|
// Entering a scope tears down whatever surface the previous one had up. The isolate does
|
|
1918
1926
|
// not own the surface, the shell does, and leaving one scope ends it — so the bridge from
|
|
1919
1927
|
// a stale surface is gone before the next scope's actions can reach for it.
|
package/src/types.ts
CHANGED
|
@@ -59,6 +59,7 @@ export interface NativeSubjects {
|
|
|
59
59
|
application: ApplicationSubject;
|
|
60
60
|
file: FileSubject;
|
|
61
61
|
"file.folder": FileSubject;
|
|
62
|
+
url: UrlSubject;
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
/** An installed application, as the `application` scope names it. */
|
|
@@ -79,6 +80,23 @@ export interface FileSubject {
|
|
|
79
80
|
isDirectory: boolean;
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/**
|
|
84
|
+
* A web address, as the `url` scope names it — a link the user is standing on, or the page the
|
|
85
|
+
* frontmost browser is showing.
|
|
86
|
+
*
|
|
87
|
+
* Two fields and no page content. `host` is separate from `url` because it is the only thing a
|
|
88
|
+
* contribution may *match* on (`CONTRIBUTION_MATCH_FIELDS`), and deriving it from the address on
|
|
89
|
+
* both sides of the boundary would be two parsers disagreeing about punycode and ports. The title,
|
|
90
|
+
* the description and the rest of the page are not here: reading them is a fetch, and a subject is
|
|
91
|
+
* something the host already knows.
|
|
92
|
+
*/
|
|
93
|
+
export interface UrlSubject {
|
|
94
|
+
/** The full address, normalized: scheme and host lowercased, no fragment. */
|
|
95
|
+
url: string;
|
|
96
|
+
/** The lowercased host, no port and no userinfo. `""` for an address that has none. */
|
|
97
|
+
host: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
82
100
|
/**
|
|
83
101
|
* **The native scopes an extension may contribute rows *into*** (architecture §11.3).
|
|
84
102
|
*
|
|
@@ -93,7 +111,7 @@ export interface FileSubject {
|
|
|
93
111
|
* **list of rows** there is something to add to. `color` has a subject and no list — it is a
|
|
94
112
|
* value Tapcue formats, not a place — so it is not here.
|
|
95
113
|
*/
|
|
96
|
-
export const CONTRIBUTABLE_SCOPE_TYPES = ["application", "file", "file.folder"] as const;
|
|
114
|
+
export const CONTRIBUTABLE_SCOPE_TYPES = ["application", "file", "file.folder", "url"] as const;
|
|
97
115
|
export type ContributableScopeType = (typeof CONTRIBUTABLE_SCOPE_TYPES)[number];
|
|
98
116
|
|
|
99
117
|
/**
|
|
@@ -103,6 +121,11 @@ export type ContributableScopeType = (typeof CONTRIBUTABLE_SCOPE_TYPES)[number];
|
|
|
103
121
|
* is a glob problem, and a predicate that could say "anything under /Users" would hand an
|
|
104
122
|
* extension every file you ever open a scope on. What a contribution may name is a *kind* of
|
|
105
123
|
* subject (this app, this file extension), never a location.
|
|
124
|
+
*
|
|
125
|
+
* `url` names `host` for the same reason and nothing else: the address itself is a location, and a
|
|
126
|
+
* predicate over it would be the glob problem again. The one concession is the `*.` prefix
|
|
127
|
+
* (`hostMatches`), because "this site" and "this site's subdomains" are genuinely different asks
|
|
128
|
+
* and neither is expressible as the other.
|
|
106
129
|
*/
|
|
107
130
|
export const CONTRIBUTION_MATCH_FIELDS: Readonly<
|
|
108
131
|
Record<ContributableScopeType, readonly string[]>
|
|
@@ -110,6 +133,7 @@ export const CONTRIBUTION_MATCH_FIELDS: Readonly<
|
|
|
110
133
|
application: ["bundleId", "name"],
|
|
111
134
|
file: ["extension", "name"],
|
|
112
135
|
"file.folder": ["name"],
|
|
136
|
+
url: ["host"],
|
|
113
137
|
});
|
|
114
138
|
|
|
115
139
|
export function isContributableScopeType(type: string): type is ContributableScopeType {
|