@rynx-ai/daemon 0.1.10 → 0.1.11-beta.2

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.
Files changed (117) hide show
  1. package/bundled-plugins/.rynx-plugin/marketplace.json +10 -0
  2. package/bundled-plugins/plugins/lark/.rynx-plugin/plugin.json +37 -0
  3. package/bundled-plugins/plugins/lark/dist/cli-mirror/mirror-index.d.ts +48 -0
  4. package/bundled-plugins/plugins/lark/dist/cli-mirror/mirror-index.js +54 -0
  5. package/bundled-plugins/plugins/lark/dist/cli-mirror/rollout-mirror.d.ts +85 -0
  6. package/bundled-plugins/plugins/lark/dist/cli-mirror/rollout-mirror.js +333 -0
  7. package/bundled-plugins/plugins/lark/dist/cli-mirror/rollout-watcher.d.ts +109 -0
  8. package/bundled-plugins/plugins/lark/dist/cli-mirror/rollout-watcher.js +347 -0
  9. package/bundled-plugins/plugins/lark/dist/host-adapters.d.ts +95 -0
  10. package/bundled-plugins/plugins/lark/dist/host-adapters.js +504 -0
  11. package/bundled-plugins/plugins/lark/dist/index.d.ts +7 -0
  12. package/bundled-plugins/plugins/lark/dist/index.js +7 -0
  13. package/bundled-plugins/plugins/lark/dist/lark-card-stream.d.ts +363 -0
  14. package/bundled-plugins/plugins/lark/dist/lark-card-stream.js +1335 -0
  15. package/bundled-plugins/plugins/lark/dist/lark-config-store.d.ts +30 -0
  16. package/bundled-plugins/plugins/lark/dist/lark-config-store.js +135 -0
  17. package/bundled-plugins/plugins/lark/dist/lark-content.d.ts +30 -0
  18. package/bundled-plugins/plugins/lark/dist/lark-content.js +138 -0
  19. package/bundled-plugins/plugins/lark/dist/lark-conversation-directory.d.ts +65 -0
  20. package/bundled-plugins/plugins/lark/dist/lark-conversation-directory.js +106 -0
  21. package/bundled-plugins/plugins/lark/dist/lark-diff-card.d.ts +25 -0
  22. package/bundled-plugins/plugins/lark/dist/lark-diff-card.js +58 -0
  23. package/bundled-plugins/plugins/lark/dist/lark-fork-transcript.d.ts +16 -0
  24. package/bundled-plugins/plugins/lark/dist/lark-fork-transcript.js +141 -0
  25. package/bundled-plugins/plugins/lark/dist/lark-resume-card.d.ts +31 -0
  26. package/bundled-plugins/plugins/lark/dist/lark-resume-card.js +105 -0
  27. package/bundled-plugins/plugins/lark/dist/lark-runtime-state-store.d.ts +18 -0
  28. package/bundled-plugins/plugins/lark/dist/lark-runtime-state-store.js +95 -0
  29. package/bundled-plugins/plugins/lark/dist/lark-sdk/client.d.ts +8 -0
  30. package/bundled-plugins/plugins/lark/dist/lark-sdk/client.js +32 -0
  31. package/bundled-plugins/plugins/lark/dist/lark-sdk/index.d.ts +1 -0
  32. package/bundled-plugins/plugins/lark/dist/lark-sdk/index.js +1 -0
  33. package/bundled-plugins/plugins/lark/dist/lark-sender.d.ts +114 -0
  34. package/bundled-plugins/plugins/lark/dist/lark-sender.js +323 -0
  35. package/bundled-plugins/plugins/lark/dist/lark-session-store.d.ts +13 -0
  36. package/bundled-plugins/plugins/lark/dist/lark-session-store.js +14 -0
  37. package/bundled-plugins/plugins/lark/dist/lark-settings-card.d.ts +55 -0
  38. package/bundled-plugins/plugins/lark/dist/lark-settings-card.js +158 -0
  39. package/bundled-plugins/plugins/lark/dist/lark-setup.d.ts +39 -0
  40. package/bundled-plugins/plugins/lark/dist/lark-setup.js +103 -0
  41. package/bundled-plugins/plugins/lark/dist/lark.d.ts +507 -0
  42. package/bundled-plugins/plugins/lark/dist/lark.js +1977 -0
  43. package/bundled-plugins/plugins/lark/dist/runtime.d.ts +13 -0
  44. package/bundled-plugins/plugins/lark/dist/runtime.js +131316 -0
  45. package/bundled-plugins/plugins/lark/dist/settings.d.ts +7 -0
  46. package/bundled-plugins/plugins/lark/dist/settings.js +126958 -0
  47. package/bundled-plugins/plugins/lark/package.json +57 -0
  48. package/bundled-plugins/plugins/lark/ui/assets/index-B2dhMlMU.css +1 -0
  49. package/bundled-plugins/plugins/lark/ui/assets/index-D9N-AkLX.js +201 -0
  50. package/bundled-plugins/plugins/lark/ui/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  51. package/bundled-plugins/plugins/lark/ui/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  52. package/bundled-plugins/plugins/lark/ui/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  53. package/bundled-plugins/plugins/lark/ui/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  54. package/bundled-plugins/plugins/lark/ui/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  55. package/bundled-plugins/plugins/lark/ui/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  56. package/bundled-plugins/plugins/lark/ui/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  57. package/bundled-plugins/plugins/lark/ui/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  58. package/bundled-plugins/plugins/lark/ui/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  59. package/bundled-plugins/plugins/lark/ui/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  60. package/bundled-plugins/plugins/lark/ui/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  61. package/bundled-plugins/plugins/lark/ui/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  62. package/bundled-plugins/plugins/lark/ui/index.html +13 -0
  63. package/dist/bundled-plugins.d.ts +15 -0
  64. package/dist/bundled-plugins.js +134 -0
  65. package/dist/cli.js +26 -208
  66. package/dist/control-deps.js +2 -79
  67. package/dist/daemon-server.d.ts +4 -1
  68. package/dist/daemon-server.js +167 -57
  69. package/dist/db.js +145 -124
  70. package/dist/index-daemon.js +25 -4
  71. package/dist/migrations/cleanup-legacy-sessions.d.ts +1 -1
  72. package/dist/migrations/cleanup-legacy-sessions.js +33 -15
  73. package/dist/official-plugins.d.ts +2 -0
  74. package/dist/official-plugins.js +2 -0
  75. package/dist/plugin-cli.d.ts +1 -3
  76. package/dist/plugin-cli.js +56 -45
  77. package/dist/plugin-host-leases.d.ts +4 -0
  78. package/dist/plugin-host-leases.js +7 -0
  79. package/dist/plugin-host-rpc.d.ts +42 -4
  80. package/dist/plugin-host-rpc.js +144 -34
  81. package/dist/plugin-installer.d.ts +24 -55
  82. package/dist/plugin-installer.js +202 -415
  83. package/dist/plugin-management-service.d.ts +30 -23
  84. package/dist/plugin-management-service.js +290 -105
  85. package/dist/plugin-marketplace.d.ts +35 -0
  86. package/dist/plugin-marketplace.js +299 -0
  87. package/dist/plugin-package.d.ts +22 -24
  88. package/dist/plugin-package.js +520 -571
  89. package/dist/plugin-skill-context.d.ts +4 -0
  90. package/dist/plugin-skill-context.js +35 -0
  91. package/dist/plugin-store.d.ts +36 -37
  92. package/dist/plugin-store.js +140 -92
  93. package/dist/plugin-supervisor.d.ts +45 -125
  94. package/dist/plugin-supervisor.js +516 -1053
  95. package/dist/pm2.js +15 -10
  96. package/dist/runtime-project-store.d.ts +9 -0
  97. package/dist/runtime-project-store.js +83 -0
  98. package/dist/session-fallback-workspace.d.ts +22 -0
  99. package/dist/session-fallback-workspace.js +120 -0
  100. package/dist/session-fork-store.d.ts +41 -0
  101. package/dist/session-fork-store.js +288 -0
  102. package/dist/session-launch-operations.d.ts +14 -2
  103. package/dist/session-launch-operations.js +34 -2
  104. package/dist/session-meta-store.d.ts +7 -9
  105. package/dist/session-meta-store.js +28 -49
  106. package/dist/setup.js +26 -14
  107. package/package.json +14 -11
  108. package/dist/channel-store.d.ts +0 -22
  109. package/dist/channel-store.js +0 -65
  110. package/dist/instance-store.d.ts +0 -38
  111. package/dist/instance-store.js +0 -85
  112. package/dist/plugin-install-restart.d.ts +0 -13
  113. package/dist/plugin-install-restart.js +0 -24
  114. package/dist/plugin-runtime-control.d.ts +0 -2
  115. package/dist/plugin-runtime-control.js +0 -37
  116. package/dist/registry.d.ts +0 -10
  117. package/dist/registry.js +0 -52
@@ -1,277 +1,230 @@
1
1
  /**
2
- * Static plugin-package materialization and inspection.
2
+ * Language-neutral plugin package materialization and static inspection.
3
3
  *
4
- * This module deliberately never imports a plugin entry point. Package code is
5
- * copied/downloaded with lifecycle scripts disabled, then only package.json and
6
- * the referenced JSON manifest are parsed.
4
+ * This module never imports plugin code and never invokes a package manager.
7
5
  */
8
6
  import { createHash } from "node:crypto";
9
7
  import { spawn } from "node:child_process";
10
- import { constants as fsConstants, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs";
11
- import { lstat as lstatAsync, open as openFile, opendir, readlink as readlinkAsync, realpath as realpathAsync, } from "node:fs/promises";
8
+ import { constants as fsConstants, cpSync, existsSync, lstatSync, opendirSync, readFileSync, realpathSync, rmSync, statSync, } from "node:fs";
9
+ import { lstat as lstatAsync, open as openFile, opendir } from "node:fs/promises";
12
10
  import { homedir } from "node:os";
13
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
11
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
14
12
  import { fileURLToPath } from "node:url";
15
- import { create as createTar } from "tar";
16
13
  import { parsePluginManifest, } from "@rynx-ai/plugin-sdk";
17
14
  const MAX_METADATA_BYTES = 1024 * 1024;
18
15
  const PACKAGE_HASH_STREAM_BYTES = 256 * 1024;
19
16
  const DEFAULT_COMMAND_ABORT_GRACE_MS = 2_000;
20
17
  export const DEFAULT_PLUGIN_PACKAGE_INSPECTION_LIMITS = Object.freeze({
21
- maxFileBytes: 128 * 1024 * 1024,
22
- maxTotalBytes: 512 * 1024 * 1024,
23
- maxEntries: 50_000,
18
+ maxFileBytes: 1024 * 1024 * 1024,
19
+ maxTotalBytes: 1024 * 1024 * 1024,
20
+ maxEntries: 25_000,
24
21
  maxDepth: 64,
25
22
  });
26
- /** Classify provenance without fetching or executing anything. */
27
23
  export function detectPluginSource(spec) {
28
- if (looksLocal(spec))
29
- return "local";
30
- if (/^(?:git\+|git:|git@|ssh:|github:|gitlab:|bitbucket:)/i.test(spec) ||
31
- /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:#.*)?$/.test(spec)) {
32
- return "git";
33
- }
34
- if (/^https?:\/\//i.test(spec))
35
- return spec.includes(".git") ? "git" : "url";
36
- return "npm";
24
+ return looksLocal(spec) ? "local" : "git";
37
25
  }
38
- /** Stable provenance for local sources; remote specs are retained verbatim. */
39
26
  export function normalizePluginSpec(spec, source) {
40
- return source === "local" ? toLocalDir(spec) : spec;
27
+ const parsed = parsePluginSourceSpec(spec, source);
28
+ return parsed.type === "local"
29
+ ? `${parsed.normalizedLocation}${parsed.subdir === "." ? "" : `#path=${parsed.subdir}`}`
30
+ : `${parsed.normalizedLocation}${parsed.subdir === "." ? "" : `#path=${parsed.subdir}`}${parsed.requested.type === "git" && parsed.requested.ref
31
+ ? `${parsed.subdir === "." ? "#" : "&"}ref=${encodeURIComponent(parsed.requested.ref)}`
32
+ : ""}`;
41
33
  }
42
- /**
43
- * Materialize a package without running package lifecycle scripts.
44
- *
45
- * Local workspace packages are archived with their internal dependency closure
46
- * and then installed into a disposable npm prefix. Other local, npm, git, and
47
- * URL specs use the same prefix flow. Every path therefore reaches inspection
48
- * with runtime dependencies present, while lifecycle scripts remain disabled.
49
- */
50
- export const materializePluginPackage = async ({ spec, source, destination, onProgress, signal, }) => {
34
+ /** CLI source syntax: local paths, Git URLs, github:owner/repo, or owner/repo. */
35
+ export function parsePluginSourceSpec(spec, forced) {
36
+ const trimmed = spec.trim();
37
+ if (!trimmed)
38
+ throw new Error("plugin source must not be empty");
39
+ const source = forced ?? detectPluginSource(trimmed);
51
40
  if (source === "local") {
52
- await materializeLocalDirectory(toLocalDir(spec), destination, onProgress, signal);
53
- return;
54
- }
55
- if (source === "git") {
56
- const checkout = join(resolve(destination, ".."), "git-checkout");
57
- await cloneGitPackage(spec, checkout, onProgress, signal);
58
- await materializeLocalDirectory(checkout, destination, onProgress, signal);
59
- return;
60
- }
61
- const archive = await acquirePublishedPackageArchive(spec, join(resolve(destination, ".."), "package-archive"), onProgress, signal);
62
- await materializeWithNpm({
63
- spec: archive,
64
- prefix: join(resolve(destination, ".."), "remote-prefix"),
65
- destination,
66
- onProgress,
67
- signal,
68
- });
69
- };
70
- async function materializeLocalDirectory(sourceDir, destination, onProgress, signal) {
71
- if (!existsSync(sourceDir) || !statSync(sourceDir).isDirectory()) {
72
- throw new Error(`local plugin source is not a directory: ${sourceDir}`);
73
- }
74
- const sourceReal = realpathSync(sourceDir);
75
- if (isWithin(sourceReal, resolve(destination))) {
76
- throw new Error("plugin staging directory must not be inside the local source");
77
- }
78
- if (!hasProductionDependencies(sourceReal)) {
79
- copyPackageTree(sourceReal, destination, true);
80
- onProgress(`copied self-contained local plugin package from ${sourceReal}`);
81
- return;
82
- }
83
- const workspaceRoot = findPnpmWorkspaceRoot(sourceReal);
84
- if (workspaceRoot) {
85
- const stagedWorkspace = await stageLocalWorkspaceClosure(sourceReal, workspaceRoot, join(resolve(destination, ".."), "workspace-packages"));
86
- onProgress(`materializing local workspace plugin with ${stagedWorkspace.packageCount - 1} workspace dependency/dependencies`);
87
- await materializeWithNpm({
88
- spec: stagedWorkspace.pluginArchive,
89
- prefix: join(resolve(destination, ".."), "local-prefix"),
90
- destination,
91
- onProgress,
92
- signal,
93
- label: "local workspace",
94
- });
95
- // The rewritten file: specs are a staging-only resolution aid. Keep the
96
- // installed plugin's own package metadata identical to its source.
97
- copyBoundedMetadataFile(join(sourceReal, "package.json"), join(destination, "package.json"), "package.json");
98
- return;
99
- }
100
- const localArchiveRoot = join(resolve(destination, ".."), "local-package");
101
- const localPackage = join(localArchiveRoot, "package");
102
- rmSync(localArchiveRoot, { recursive: true, force: true });
103
- copyPackageTree(sourceReal, localPackage, true);
104
- const localArchive = join(localArchiveRoot, "package.tgz");
105
- await createPackageArchive(localPackage, localArchive);
106
- await materializeWithNpm({
107
- spec: localArchive,
108
- prefix: join(resolve(destination, ".."), "local-prefix"),
109
- destination,
110
- onProgress,
111
- signal,
112
- label: "local",
113
- });
41
+ const { location, subdir } = splitSourceFragment(trimmed);
42
+ const localPath = toLocalDir(location);
43
+ const root = realpathSync(localPath);
44
+ return {
45
+ type: "local",
46
+ requested: { type: "local", path: root, ...(subdir === "." ? {} : { subdir }) },
47
+ normalizedLocation: root,
48
+ subdir,
49
+ };
50
+ }
51
+ const { url, ref, subdir } = parseGitSpec(trimmed);
52
+ const normalizedUrl = normalizeGitUrl(url);
53
+ return {
54
+ type: "git",
55
+ requested: {
56
+ type: "git",
57
+ url: normalizedUrl,
58
+ ...(subdir === "." ? {} : { subdir }),
59
+ ...(ref ? { ref } : {}),
60
+ },
61
+ normalizedLocation: normalizedUrl,
62
+ subdir,
63
+ };
114
64
  }
115
- async function acquirePublishedPackageArchive(spec, archiveRoot, onProgress, signal) {
116
- rmSync(archiveRoot, { recursive: true, force: true });
117
- mkdirSync(archiveRoot, { recursive: true, mode: 0o700 });
118
- await runNpm([
119
- "pack",
120
- "--ignore-scripts",
121
- "--pack-destination",
122
- archiveRoot,
123
- "--",
124
- spec,
125
- ], onProgress, signal);
126
- const archives = readdirSync(archiveRoot)
127
- .filter((name) => name.endsWith(".tgz"))
128
- .map((name) => join(archiveRoot, name));
129
- if (archives.length !== 1 || !statSync(archives[0]).isFile()) {
130
- throw new Error("package source did not produce exactly one npm archive");
131
- }
132
- return archives[0];
65
+ export function pluginSourceFingerprint(source) {
66
+ const input = source.type === "git"
67
+ ? `git\0${source.normalizedLocation}\0${source.subdir}`
68
+ : `local\0${source.normalizedLocation}\0${source.subdir}`;
69
+ return createHash("sha256").update(input).digest("hex").slice(0, 32);
133
70
  }
134
- async function cloneGitPackage(spec, destination, onProgress, signal) {
135
- const { url, ref } = parseGitSpec(spec);
71
+ export const materializePluginPackage = async ({ spec, source, destination, onProgress, signal, }) => {
72
+ signal?.throwIfAborted();
73
+ const normalized = parsePluginSourceSpec(spec, source);
136
74
  rmSync(destination, { recursive: true, force: true });
137
- const args = ["clone", "--no-checkout", "--filter=blob:none", "--", url, destination];
138
- await runPluginPackageCommand("git", args, onProgress, { signal });
139
- await runPluginPackageCommand("git", ["-C", destination, "checkout", "--detach", ref ?? "HEAD"], onProgress, { signal });
140
- }
75
+ if (normalized.type === "local") {
76
+ const selected = selectSubdirectory(normalized.normalizedLocation, normalized.subdir);
77
+ copyClosedSource(selected, destination, signal);
78
+ onProgress(`copied local plugin snapshot from ${selected}`);
79
+ return {
80
+ requestedSource: normalized.requested,
81
+ resolvedRevision: "local",
82
+ sourceFingerprint: pluginSourceFingerprint(normalized),
83
+ };
84
+ }
85
+ const checkout = join(resolve(destination, ".."), "git-checkout");
86
+ rmSync(checkout, { recursive: true, force: true });
87
+ const request = normalized.requested;
88
+ await runPluginPackageCommand("git", ["clone", "--no-checkout", "--no-recurse-submodules", "--filter=blob:none", "--", request.url, checkout], onProgress, { signal });
89
+ const revision = await capturePluginPackageCommand("git", [
90
+ "-C",
91
+ checkout,
92
+ "rev-parse",
93
+ "--verify",
94
+ `${request.ref ?? request.commit ?? "HEAD"}^{commit}`,
95
+ ], { signal });
96
+ await validateGitPackageTree(checkout, revision, normalized.subdir, signal);
97
+ if (normalized.subdir !== ".") {
98
+ await runPluginPackageCommand("git", ["-C", checkout, "sparse-checkout", "init", "--no-cone"], onProgress, { signal });
99
+ await runPluginPackageCommand("git", ["-C", checkout, "sparse-checkout", "set", "--no-cone", "--", normalized.subdir], onProgress, { signal });
100
+ }
101
+ await runPluginPackageCommand("git", ["-C", checkout, "checkout", "--detach", revision], onProgress, { signal });
102
+ const selected = selectSubdirectory(checkout, normalized.subdir);
103
+ copyClosedSource(selected, destination, signal);
104
+ onProgress(`checked out plugin source at ${revision}`);
105
+ return {
106
+ requestedSource: normalized.requested,
107
+ resolvedRevision: revision,
108
+ sourceFingerprint: pluginSourceFingerprint(normalized),
109
+ };
110
+ };
141
111
  export function parseGitSpec(spec) {
142
112
  let value = spec.trim();
143
- let shorthand = false;
144
113
  if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:#.*)?$/.test(value)) {
145
114
  value = `https://github.com/${value}`;
146
- shorthand = true;
147
115
  }
148
116
  else if (value.startsWith("github:")) {
149
117
  value = `https://github.com/${value.slice("github:".length)}`;
150
- shorthand = true;
151
- }
152
- else if (value.startsWith("gitlab:")) {
153
- value = `https://gitlab.com/${value.slice("gitlab:".length)}`;
154
- shorthand = true;
155
- }
156
- else if (value.startsWith("bitbucket:")) {
157
- value = `https://bitbucket.org/${value.slice("bitbucket:".length)}`;
158
- shorthand = true;
159
118
  }
160
119
  if (value.startsWith("git+"))
161
120
  value = value.slice(4);
162
- const hash = value.lastIndexOf("#");
163
- const ref = hash >= 0 ? value.slice(hash + 1).trim() : undefined;
164
- const url = hash >= 0 ? value.slice(0, hash) : value;
165
- if (!url || url.startsWith("-") || /[\r\n\0]/.test(url)) {
121
+ const { location, ref, subdir } = splitSourceFragment(value);
122
+ if (!location || location.startsWith("-") || /[\r\n\0]/.test(location)) {
166
123
  throw new Error("git plugin source has an invalid URL");
167
124
  }
168
125
  if (ref && (ref.startsWith("-") || /[\r\n\0]/.test(ref))) {
169
126
  throw new Error("git plugin source has an invalid ref");
170
127
  }
171
- const normalizedUrl = shorthand && !url.endsWith(".git") ? `${url}.git` : url;
172
- return { url: normalizedUrl, ...(ref ? { ref } : {}) };
128
+ return { url: location, ...(ref ? { ref } : {}), subdir };
173
129
  }
174
- async function materializeWithNpm({ spec, prefix, destination, onProgress, signal, installLinks = false, label, }) {
175
- mkdirSync(prefix, { recursive: true, mode: 0o700 });
176
- writeFileSync(join(prefix, "package.json"), `${JSON.stringify({ name: "rynx-plugin-materializer", private: true }, null, 2)}\n`, { mode: 0o600 });
177
- onProgress(`materializing ${label ?? (installLinks ? "local" : "remote")} plugin ${spec}`);
178
- await runNpm([
179
- "install",
180
- "--prefix",
181
- prefix,
182
- "--ignore-scripts",
183
- "--no-audit",
184
- "--no-fund",
185
- "--package-lock=false",
186
- "--install-strategy=nested",
187
- ...(installLinks ? ["--install-links=true"] : []),
188
- "--save-exact",
189
- "--",
190
- spec,
191
- ], onProgress, signal);
192
- const materializerPackage = readJsonObject(join(prefix, "package.json"), "materializer package.json");
193
- const dependencies = materializerPackage.dependencies;
194
- if (!isRecord(dependencies)) {
195
- throw new Error("npm did not record the materialized plugin package");
196
- }
197
- const names = Object.keys(dependencies);
198
- if (names.length !== 1 || !isSafeNpmPackageName(names[0])) {
199
- throw new Error("npm materialization did not resolve exactly one safe package name");
200
- }
201
- const packageDir = join(prefix, "node_modules", ...names[0].split("/"));
202
- const packageReal = requireContainedFile(prefix, packageDir, "materialized package", "directory");
203
- copyPackageTree(packageReal, destination, false);
130
+ export function normalizeGitUrl(value) {
131
+ if (/^git@/i.test(value)) {
132
+ if (/[\r\n\0?#]/.test(value))
133
+ throw new Error("git source URL is invalid");
134
+ const match = /^git@([^/:]+):(.+)$/.exec(value);
135
+ if (!match || !match[2] || match[2].startsWith("/")) {
136
+ throw new Error("git source URL is invalid");
137
+ }
138
+ const repositoryPath = match[2].replace(/\/+$/, "").replace(/\.git$/, "");
139
+ if (!repositoryPath)
140
+ throw new Error("git source URL is invalid");
141
+ return `git@${match[1].toLowerCase()}:${repositoryPath}`;
142
+ }
143
+ let parsed;
144
+ try {
145
+ parsed = new URL(value);
146
+ }
147
+ catch {
148
+ throw new Error("git source URL is invalid");
149
+ }
150
+ if ((parsed.protocol !== "https:" &&
151
+ parsed.protocol !== "http:" &&
152
+ parsed.protocol !== "ssh:") ||
153
+ (parsed.protocol !== "ssh:" && parsed.username) ||
154
+ parsed.password ||
155
+ parsed.search ||
156
+ parsed.hash) {
157
+ throw new Error("git source URL must be credential-free HTTP(S) without query or fragment");
158
+ }
159
+ parsed.protocol = parsed.protocol.toLowerCase();
160
+ parsed.hostname = parsed.hostname.toLowerCase();
161
+ if ((parsed.protocol === "https:" && parsed.port === "443") ||
162
+ (parsed.protocol === "http:" && parsed.port === "80") ||
163
+ (parsed.protocol === "ssh:" && parsed.port === "22")) {
164
+ parsed.port = "";
165
+ }
166
+ parsed.pathname = parsed.pathname.replace(/\/+$/, "").replace(/\.git$/, "");
167
+ return parsed.toString().replace(/\/$/, "");
204
168
  }
205
- /** Parse and validate static metadata, validate all executable entry paths, and hash the tree. */
206
169
  export async function inspectPluginPackage(packageDir, options = {}) {
207
- throwIfInspectionAborted(options.signal);
170
+ options.signal?.throwIfAborted();
208
171
  const packageRoot = realpathSync(packageDir);
209
- const integrity = await hashPackageTree(packageRoot, normalizedInspectionLimits(options.limits), options.signal);
210
- throwIfInspectionAborted(options.signal);
211
- const packageJsonPath = requireContainedFile(packageRoot, join(packageRoot, "package.json"), "package.json", "file");
212
- const packageJson = readJsonObject(packageJsonPath, "package.json");
213
- if (!isRecord(packageJson.rynx) || typeof packageJson.rynx.manifest !== "string") {
214
- throw new Error("package.json must declare `rynx.manifest` as a static JSON path");
215
- }
216
- const manifestPath = resolvePackageEntry(packageRoot, packageJson.rynx.manifest, "rynx.manifest");
172
+ const integrity = await validatePluginPackageSnapshot(packageRoot, options);
173
+ const manifestPath = requireContainedPath(packageRoot, join(packageRoot, ".rynx-plugin", "plugin.json"), ".rynx-plugin/plugin.json", "file");
217
174
  const manifest = parsePluginManifest(readJsonObject(manifestPath, "plugin manifest"));
218
- if (options.requireEntries !== false) {
219
- resolvePackageEntry(packageRoot, manifest.runtimeEntry, "runtimeEntry");
220
- if (manifest.cliEntry)
221
- resolvePackageEntry(packageRoot, manifest.cliEntry, "cliEntry");
222
- }
223
- const version = typeof packageJson.version === "string" && packageJson.version.trim()
224
- ? packageJson.version
225
- : undefined;
226
- return {
227
- manifest,
228
- ...(version ? { version } : {}),
229
- integrity,
230
- };
231
- }
232
- /** Run package/dependency build scripts only after explicit user approval. */
233
- export async function rebuildPluginPackage(packageDir, onProgress, signal) {
234
- await runNpm([
235
- "rebuild",
236
- "--prefix",
237
- realpathSync(packageDir),
238
- "--foreground-scripts",
239
- "--no-audit",
240
- "--no-fund",
241
- ], onProgress, signal);
242
- }
243
- function looksLocal(spec) {
244
- if (spec.startsWith("file:"))
245
- return true;
246
- if (spec === "~" || spec.startsWith("~/"))
247
- return true;
248
- if (spec.startsWith("./") || spec.startsWith("../") || isAbsolute(spec))
249
- return true;
250
- try {
251
- return existsSync(spec) && statSync(spec).isDirectory();
252
- }
253
- catch {
254
- return false;
175
+ if (manifest.runtime) {
176
+ if ("entry" in manifest.runtime) {
177
+ resolvePackagePath(packageRoot, manifest.runtime.entry, "runtime.entry", "file");
178
+ }
179
+ else {
180
+ const command = resolvePackagePath(packageRoot, manifest.runtime.command, "runtime.command", "file");
181
+ if (process.platform !== "win32" && (statSync(command).mode & 0o111) === 0) {
182
+ throw new Error("runtime.command must be executable");
183
+ }
184
+ }
255
185
  }
256
- }
257
- function toLocalDir(spec) {
258
- let path;
259
- if (spec.startsWith("file:")) {
260
- try {
261
- path = fileURLToPath(spec);
186
+ if (manifest.settings?.background) {
187
+ if ("entry" in manifest.settings.background) {
188
+ resolvePackagePath(packageRoot, manifest.settings.background.entry, "settings.background.entry", "file");
262
189
  }
263
- catch {
264
- path = spec.slice("file:".length);
190
+ else {
191
+ const command = resolvePackagePath(packageRoot, manifest.settings.background.command, "settings.background.command", "file");
192
+ if (process.platform !== "win32" && (statSync(command).mode & 0o111) === 0) {
193
+ throw new Error("settings.background.command must be executable");
194
+ }
195
+ }
196
+ }
197
+ if (manifest.commands) {
198
+ const command = resolvePackagePath(packageRoot, manifest.commands.command, "commands.command", "file");
199
+ if (process.platform !== "win32" && (statSync(command).mode & 0o111) === 0) {
200
+ throw new Error("commands.command must be executable");
265
201
  }
266
202
  }
267
- else {
268
- path = spec;
203
+ if (manifest.skills) {
204
+ resolvePackagePath(packageRoot, manifest.skills, "skills", "directory");
269
205
  }
270
- if (path === "~" || path.startsWith("~/"))
271
- path = join(homedir(), path.slice(1));
272
- return resolve(path);
206
+ if (manifest.settings) {
207
+ resolvePackagePath(packageRoot, manifest.settings.ui.entry, "settings.ui.entry", "file");
208
+ }
209
+ return { manifest, version: manifest.version, integrity };
210
+ }
211
+ /** Validate and hash a closed package/catalog snapshot without executing it. */
212
+ export async function validatePluginPackageSnapshot(packageDir, options = {}) {
213
+ options.signal?.throwIfAborted();
214
+ return await hashPackageTree(realpathSync(packageDir), normalizedInspectionLimits(options.limits), options.signal);
215
+ }
216
+ export function resolvePackagePath(packageRoot, entry, label, kind) {
217
+ if (!entry.startsWith("./") || entry.includes("\\") || entry.includes("\0")) {
218
+ throw new Error(`${label} must be a package-root-relative POSIX path starting with ./`);
219
+ }
220
+ return requireContainedPath(packageRoot, resolve(packageRoot, entry), label, kind);
221
+ }
222
+ function selectSubdirectory(root, subdir) {
223
+ const candidate = subdir === "." ? root : resolve(root, subdir);
224
+ return requireContainedPath(root, candidate, "plugin source subdirectory", "directory");
273
225
  }
274
- function copyPackageTree(source, destination, excludeNodeModules) {
226
+ function copyClosedSource(source, destination, signal) {
227
+ assertClosedSourceTree(source, DEFAULT_PLUGIN_PACKAGE_INSPECTION_LIMITS, signal);
275
228
  cpSync(source, destination, {
276
229
  recursive: true,
277
230
  errorOnExist: true,
@@ -284,132 +237,68 @@ function copyPackageTree(source, destination, excludeNodeModules) {
284
237
  const segments = rel.split(sep);
285
238
  if (segments.includes(".git"))
286
239
  return false;
287
- // npm generates command shims as absolute symlinks into its disposable
288
- // materialization prefix. Plugin entries resolve tools by package path,
289
- // so these non-runtime shims must not escape into the immutable closure.
290
- if (segments.includes(".bin"))
291
- return false;
292
- return !excludeNodeModules || !segments.includes("node_modules");
240
+ return !(segments.includes("node_modules") && lstatSync(entry).isSymbolicLink());
293
241
  },
294
242
  });
295
243
  }
296
- function findPnpmWorkspaceRoot(start) {
297
- let current = start;
298
- while (true) {
299
- if (existsSync(join(current, "pnpm-workspace.yaml")))
300
- return current;
301
- const parent = dirname(current);
302
- if (parent === current)
303
- return undefined;
304
- current = parent;
244
+ /**
245
+ * Check the source before copying. `cp` may expand hardlinks into independent
246
+ * files, so validating only the copied snapshot would lose that provenance.
247
+ */
248
+ function assertClosedSourceTree(root, limits, signal) {
249
+ const rootInfo = lstatSync(root);
250
+ if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) {
251
+ throw new Error("plugin source must be a regular directory");
305
252
  }
306
- }
307
- async function stageLocalWorkspaceClosure(pluginRoot, workspaceRoot, stagingRoot) {
308
- const packages = new Map();
309
- const workspaceNodeModules = existsSync(join(workspaceRoot, "node_modules"))
310
- ? realpathSync(join(workspaceRoot, "node_modules"))
311
- : undefined;
312
- const visit = (source) => {
313
- const sourceReal = realpathSync(source);
314
- const packageJson = readJsonObject(join(sourceReal, "package.json"), "package.json");
315
- const name = packageJson.name;
316
- if (typeof name !== "string" || !isSafeNpmPackageName(name)) {
317
- throw new Error(`local workspace package has an invalid name: ${String(name)}`);
253
+ let entries = 0;
254
+ let totalBytes = 0;
255
+ visit(root, "", 0);
256
+ function visit(current, rel, depth) {
257
+ signal?.throwIfAborted();
258
+ entries += 1;
259
+ if (entries > limits.maxEntries) {
260
+ throw new Error(`plugin package exceeds ${limits.maxEntries} entries`);
318
261
  }
319
- const existing = packages.get(name);
320
- if (existing) {
321
- if (existing.source !== sourceReal) {
322
- throw new Error(`local workspace contains duplicate package name: ${name}`);
323
- }
324
- return;
262
+ if (depth > limits.maxDepth) {
263
+ throw new Error(`plugin package exceeds maximum depth ${limits.maxDepth}`);
325
264
  }
326
- const key = createHash("sha256").update(name).digest("hex").slice(0, 16);
327
- packages.set(name, {
328
- source: sourceReal,
329
- staged: join(stagingRoot, "packages", key),
330
- archive: join(stagingRoot, "archives", `${key}.tgz`),
331
- });
332
- for (const field of ["dependencies", "optionalDependencies", "peerDependencies"]) {
333
- const dependencies = packageJson[field];
334
- if (!isRecord(dependencies))
335
- continue;
336
- for (const [dependencyName, spec] of Object.entries(dependencies)) {
337
- const dependencyPath = join(sourceReal, "node_modules", ...dependencyName.split("/"));
338
- if (!existsSync(dependencyPath)) {
339
- if (typeof spec === "string" && spec.startsWith("workspace:")) {
340
- throw new Error(`local workspace dependency is not installed: ${name} -> ${dependencyName}`);
341
- }
342
- continue;
343
- }
344
- const dependencyReal = realpathSync(dependencyPath);
345
- if (!isWithin(workspaceRoot, dependencyReal) ||
346
- (workspaceNodeModules && isWithin(workspaceNodeModules, dependencyReal))) {
347
- continue;
348
- }
349
- const dependencyPackage = readJsonObject(join(dependencyReal, "package.json"), `${dependencyName} package.json`);
350
- if (dependencyPackage.name !== dependencyName) {
351
- throw new Error(`local workspace dependency identity mismatch: ${dependencyName}`);
265
+ const info = lstatSync(current);
266
+ const normalized = rel.split(sep).join("/");
267
+ if (info.isSymbolicLink() && rel.split(sep).includes("node_modules"))
268
+ return;
269
+ if (info.isDirectory()) {
270
+ const directory = opendirSync(current);
271
+ try {
272
+ for (;;) {
273
+ const entry = directory.readSync();
274
+ if (!entry)
275
+ break;
276
+ if (entry.name === ".git")
277
+ continue;
278
+ visit(join(current, entry.name), join(rel, entry.name), depth + 1);
352
279
  }
353
- visit(dependencyReal);
354
280
  }
355
- }
356
- };
357
- rmSync(stagingRoot, { recursive: true, force: true });
358
- mkdirSync(stagingRoot, { recursive: true, mode: 0o700 });
359
- visit(pluginRoot);
360
- for (const entry of packages.values())
361
- copyPackageTree(entry.source, entry.staged, true);
362
- for (const entry of packages.values()) {
363
- const file = join(entry.staged, "package.json");
364
- const packageJson = readJsonObject(file, "staged package.json");
365
- for (const field of ["dependencies", "optionalDependencies", "peerDependencies"]) {
366
- const dependencies = packageJson[field];
367
- if (!isRecord(dependencies))
368
- continue;
369
- for (const dependencyName of Object.keys(dependencies)) {
370
- const target = packages.get(dependencyName);
371
- if (target)
372
- dependencies[dependencyName] = `file:${target.archive}`;
281
+ finally {
282
+ directory.closeSync();
373
283
  }
284
+ return;
285
+ }
286
+ if (!info.isFile() || info.isSymbolicLink()) {
287
+ throw new Error(`plugin package accepts regular files and directories only: ${normalized}`);
288
+ }
289
+ if (info.nlink !== 1) {
290
+ throw new Error(`plugin package contains a hardlinked file: ${normalized}`);
291
+ }
292
+ if (info.size > limits.maxFileBytes) {
293
+ throw new Error(`plugin package file exceeds ${limits.maxFileBytes} bytes: ${normalized}`);
294
+ }
295
+ totalBytes += info.size;
296
+ if (totalBytes > limits.maxTotalBytes) {
297
+ throw new Error(`plugin package exceeds ${limits.maxTotalBytes} total bytes`);
374
298
  }
375
- writeFileSync(file, `${JSON.stringify(packageJson, null, 2)}\n`, { mode: 0o600 });
376
- }
377
- await Promise.all([...packages.values()].map((entry) => createPackageArchive(entry.staged, entry.archive)));
378
- const pluginName = readJsonObject(join(pluginRoot, "package.json"), "package.json").name;
379
- const plugin = typeof pluginName === "string" ? packages.get(pluginName) : undefined;
380
- if (!plugin)
381
- throw new Error("local workspace plugin was not staged");
382
- return { pluginArchive: plugin.archive, packageCount: packages.size };
383
- }
384
- async function createPackageArchive(packageRoot, archive) {
385
- mkdirSync(dirname(archive), { recursive: true, mode: 0o700 });
386
- await createTar({
387
- cwd: packageRoot,
388
- file: archive,
389
- gzip: true,
390
- portable: true,
391
- prefix: "package",
392
- noMtime: true,
393
- }, readdirSync(packageRoot));
394
- }
395
- function hasProductionDependencies(packageRoot) {
396
- const packageJson = readJsonObject(join(packageRoot, "package.json"), "package.json");
397
- return ["dependencies", "optionalDependencies", "peerDependencies"].some((key) => {
398
- const value = packageJson[key];
399
- return isRecord(value) && Object.keys(value).length > 0;
400
- });
401
- }
402
- function resolvePackageEntry(packageRoot, entry, label) {
403
- if (!entry.startsWith("./") || entry.includes("\\") || entry.includes("\0")) {
404
- throw new Error(`${label} must be a package-root-relative POSIX path starting with ./`);
405
- }
406
- const segments = entry.slice(2).split("/");
407
- if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
408
- throw new Error(`${label} must not contain empty, . or .. segments`);
409
299
  }
410
- return requireContainedFile(packageRoot, resolve(packageRoot, entry), label, "file");
411
300
  }
412
- function requireContainedFile(root, candidate, label, kind) {
301
+ function requireContainedPath(root, candidate, label, kind) {
413
302
  let rootReal;
414
303
  let candidateReal;
415
304
  try {
@@ -422,9 +311,9 @@ function requireContainedFile(root, candidate, label, kind) {
422
311
  if (!isWithin(rootReal, candidateReal)) {
423
312
  throw new Error(`${label} resolves outside the plugin package`);
424
313
  }
425
- const stat = statSync(candidateReal);
426
- if (kind === "file" ? !stat.isFile() : !stat.isDirectory()) {
427
- throw new Error(`${label} must be a ${kind}`);
314
+ const info = lstatSync(candidateReal);
315
+ if (info.isSymbolicLink() || (kind === "file" ? !info.isFile() : !info.isDirectory())) {
316
+ throw new Error(`${label} must be a regular ${kind}`);
428
317
  }
429
318
  return candidateReal;
430
319
  }
@@ -432,40 +321,78 @@ function isWithin(root, candidate) {
432
321
  const rel = relative(root, candidate);
433
322
  return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
434
323
  }
435
- function readJsonObject(path, label) {
436
- let raw;
437
- try {
438
- const stat = statSync(path);
439
- if (!stat.isFile())
440
- throw new Error("not a file");
441
- if (stat.size > MAX_METADATA_BYTES)
442
- throw new Error("metadata is too large");
443
- raw = readFileSync(path, "utf8");
444
- }
445
- catch (error) {
446
- throw new Error(`cannot read ${label}: ${messageOf(error)}`);
324
+ function splitSourceFragment(spec) {
325
+ const hash = spec.indexOf("#");
326
+ const location = hash < 0 ? spec : spec.slice(0, hash);
327
+ const fragment = hash < 0 ? "" : spec.slice(hash + 1);
328
+ let ref;
329
+ let subdir = ".";
330
+ if (fragment && !fragment.includes("=")) {
331
+ ref = decodeURIComponent(fragment);
332
+ }
333
+ else if (fragment) {
334
+ const params = new URLSearchParams(fragment);
335
+ const keys = [...params.keys()];
336
+ if (keys.some((key) => key !== "ref" && key !== "path")) {
337
+ throw new Error("plugin source fragment supports only ref and path");
338
+ }
339
+ ref = params.get("ref") || undefined;
340
+ subdir = normalizeSubdir(params.get("path") || ".");
447
341
  }
342
+ return { location, ...(ref ? { ref } : {}), subdir };
343
+ }
344
+ function normalizeSubdir(value) {
345
+ if (value === "." || value === "")
346
+ return ".";
347
+ if (value.startsWith("/") ||
348
+ value.includes("\\") ||
349
+ value.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
350
+ throw new Error("plugin source subdirectory must be a normalized relative POSIX path");
351
+ }
352
+ return value;
353
+ }
354
+ function looksLocal(spec) {
355
+ const location = spec.split("#", 1)[0];
356
+ if (location.startsWith("file:") || location === "~" || location.startsWith("~/"))
357
+ return true;
358
+ if (location.startsWith("./") || location.startsWith("../") || isAbsolute(location))
359
+ return true;
448
360
  try {
449
- const parsed = JSON.parse(raw);
450
- if (!isRecord(parsed))
451
- throw new Error("must contain a JSON object");
452
- return parsed;
361
+ return existsSync(location) && statSync(location).isDirectory();
453
362
  }
454
- catch (error) {
455
- throw new Error(`invalid ${label}: ${messageOf(error)}`);
363
+ catch {
364
+ return false;
456
365
  }
457
366
  }
458
- function copyBoundedMetadataFile(source, destination, label) {
367
+ function toLocalDir(spec) {
368
+ let value = spec;
369
+ if (value.startsWith("file:")) {
370
+ try {
371
+ value = fileURLToPath(value);
372
+ }
373
+ catch {
374
+ value = value.slice("file:".length);
375
+ }
376
+ }
377
+ if (value === "~" || value.startsWith("~/"))
378
+ value = join(homedir(), value.slice(1));
379
+ return resolve(value);
380
+ }
381
+ function readJsonObject(path, label) {
459
382
  try {
460
- const stat = statSync(source);
461
- if (!stat.isFile())
462
- throw new Error("not a file");
463
- if (stat.size > MAX_METADATA_BYTES)
383
+ const info = lstatSync(path);
384
+ if (!info.isFile() || info.isSymbolicLink())
385
+ throw new Error("not a regular file");
386
+ if (info.size > MAX_METADATA_BYTES)
464
387
  throw new Error("metadata is too large");
465
- copyFileSync(source, destination);
388
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
389
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
390
+ throw new Error("must contain a JSON object");
391
+ }
392
+ return parsed;
466
393
  }
467
394
  catch (error) {
468
- throw new Error(`cannot copy ${label}: ${messageOf(error)}`);
395
+ throw new Error(`invalid ${label}: ${error instanceof Error ? error.message : String(error)}`);
469
396
  }
470
397
  }
471
398
  async function hashPackageTree(root, limits, signal) {
@@ -476,99 +403,60 @@ async function hashPackageTree(root, limits, signal) {
476
403
  await visit(root, "", 0);
477
404
  return `sha256-${hash.digest("base64")}`;
478
405
  async function visit(path, rel, depth) {
479
- throwIfInspectionAborted(signal);
406
+ signal?.throwIfAborted();
480
407
  entryCount += 1;
481
- if (entryCount > limits.maxEntries) {
408
+ if (entryCount > limits.maxEntries)
482
409
  throw new Error(`plugin package exceeds ${limits.maxEntries} entries`);
483
- }
484
- if (depth > limits.maxDepth) {
410
+ if (depth > limits.maxDepth)
485
411
  throw new Error(`plugin package exceeds maximum depth ${limits.maxDepth}`);
486
- }
487
- const stat = await lstatAsync(path);
412
+ const info = await lstatAsync(path);
488
413
  const normalized = rel.split(sep).join("/");
489
- if (stat.isDirectory()) {
414
+ if (info.isDirectory()) {
490
415
  hash.update(`d\0${normalized}\0`);
491
416
  const names = [];
492
417
  const directory = await opendir(path);
493
- for await (const entry of directory) {
494
- throwIfInspectionAborted(signal);
418
+ for await (const entry of directory)
495
419
  names.push(entry.name);
496
- if (entryCount + names.length > limits.maxEntries) {
497
- throw new Error(`plugin package exceeds ${limits.maxEntries} entries`);
498
- }
499
- }
500
- for (const name of names.sort()) {
420
+ for (const name of names.sort())
501
421
  await visit(join(path, name), join(rel, name), depth + 1);
502
- }
503
422
  return;
504
423
  }
505
- if (stat.isFile()) {
506
- if (stat.size > limits.maxFileBytes) {
507
- throw new Error(`plugin package file exceeds ${limits.maxFileBytes} bytes: ${normalized}`);
508
- }
509
- if (totalBytes + stat.size > limits.maxTotalBytes) {
510
- throw new Error(`plugin package exceeds ${limits.maxTotalBytes} total bytes`);
511
- }
512
- totalBytes += stat.size;
513
- hash.update(`f\0${normalized}\0${stat.size}\0`);
514
- const noFollow = process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW;
515
- const file = await openFile(path, fsConstants.O_RDONLY | noFollow);
516
- try {
517
- const before = await file.stat();
518
- if (!before.isFile() || before.size !== stat.size) {
519
- throw new Error(`plugin package file changed during inspection: ${normalized}`);
520
- }
521
- let streamedBytes = 0;
522
- const stream = file.createReadStream({
523
- autoClose: false,
524
- highWaterMark: PACKAGE_HASH_STREAM_BYTES,
525
- ...(signal ? { signal } : {}),
526
- });
527
- for await (const chunk of stream) {
528
- throwIfInspectionAborted(signal);
529
- const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
530
- streamedBytes += bytes.byteLength;
531
- if (streamedBytes > limits.maxFileBytes ||
532
- totalBytes - stat.size + streamedBytes > limits.maxTotalBytes) {
533
- throw new Error(`plugin package file changed during inspection: ${normalized}`);
534
- }
535
- hash.update(bytes);
536
- }
537
- const after = await file.stat();
538
- if (streamedBytes !== before.size ||
539
- after.size !== before.size ||
540
- after.mtimeMs !== before.mtimeMs) {
541
- throw new Error(`plugin package file changed during inspection: ${normalized}`);
542
- }
543
- }
544
- finally {
545
- await file.close();
546
- }
547
- return;
424
+ if (!info.isFile()) {
425
+ throw new Error(`plugin package accepts regular files and directories only: ${normalized}`);
548
426
  }
549
- if (stat.isSymbolicLink()) {
550
- let target;
551
- try {
552
- target = await realpathAsync(path);
553
- }
554
- catch {
555
- throw new Error(`plugin package contains a broken symlink: ${normalized}`);
556
- }
557
- if (!isWithin(root, target)) {
558
- throw new Error(`plugin package symlink resolves outside the package: ${normalized}`);
427
+ if (info.nlink !== 1)
428
+ throw new Error(`plugin package contains a hardlinked file: ${normalized}`);
429
+ if (info.size > limits.maxFileBytes) {
430
+ throw new Error(`plugin package file exceeds ${limits.maxFileBytes} bytes: ${normalized}`);
431
+ }
432
+ totalBytes += info.size;
433
+ if (totalBytes > limits.maxTotalBytes) {
434
+ throw new Error(`plugin package exceeds ${limits.maxTotalBytes} total bytes`);
435
+ }
436
+ hash.update(`f\0${normalized}\0${info.mode & 0o777}\0${info.size}\0`);
437
+ const noFollow = process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW;
438
+ const file = await openFile(path, fsConstants.O_RDONLY | noFollow);
439
+ try {
440
+ let streamedBytes = 0;
441
+ for await (const chunk of file.createReadStream({
442
+ autoClose: false,
443
+ highWaterMark: PACKAGE_HASH_STREAM_BYTES,
444
+ ...(signal ? { signal } : {}),
445
+ })) {
446
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
447
+ streamedBytes += bytes.byteLength;
448
+ hash.update(bytes);
559
449
  }
560
- const link = await readlinkAsync(path);
561
- hash.update(`l\0${normalized}\0${link}\0`);
562
- return;
450
+ if (streamedBytes !== info.size)
451
+ throw new Error(`plugin package changed during inspection: ${normalized}`);
452
+ }
453
+ finally {
454
+ await file.close();
563
455
  }
564
- throw new Error(`unsupported package entry: ${normalized}`);
565
456
  }
566
457
  }
567
458
  function normalizedInspectionLimits(overrides) {
568
- const limits = {
569
- ...DEFAULT_PLUGIN_PACKAGE_INSPECTION_LIMITS,
570
- ...overrides,
571
- };
459
+ const limits = { ...DEFAULT_PLUGIN_PACKAGE_INSPECTION_LIMITS, ...overrides };
572
460
  for (const [name, value] of Object.entries(limits)) {
573
461
  if (!Number.isSafeInteger(value) || value <= 0) {
574
462
  throw new Error(`plugin package inspection ${name} must be a positive safe integer`);
@@ -576,157 +464,218 @@ function normalizedInspectionLimits(overrides) {
576
464
  }
577
465
  return limits;
578
466
  }
579
- function throwIfInspectionAborted(signal) {
580
- if (signal?.aborted) {
581
- throw new Error("plugin package inspection aborted", { cause: signal.reason });
582
- }
583
- }
584
- function isSafeNpmPackageName(name) {
585
- return /^(?:@[A-Za-z0-9][A-Za-z0-9._-]*\/)?[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name);
586
- }
587
- function runNpm(args, onProgress, signal) {
588
- return runPluginPackageCommand("npm", args, onProgress, { signal });
589
- }
590
- /**
591
- * Run one package tool without a shell and tear down its full POSIX process
592
- * group on cancellation. Windows cannot signal process groups through Node,
593
- * so it safely degrades to TERM→KILL on the direct child.
594
- */
595
- export function runPluginPackageCommand(command, args, onProgress, options = {}) {
467
+ function validateGitPackageTree(checkout, revision, subdir, signal) {
596
468
  return new Promise((resolveDone, reject) => {
597
- const { signal, cwd } = options;
598
- const abortGraceMs = options.abortGraceMs ?? DEFAULT_COMMAND_ABORT_GRACE_MS;
599
- if (!Number.isSafeInteger(abortGraceMs) || abortGraceMs <= 0 || abortGraceMs > 60_000) {
600
- reject(new Error("plugin command abort grace must be 1-60000ms"));
601
- return;
602
- }
603
469
  if (signal?.aborted) {
604
470
  reject(new Error("plugin installation aborted"));
605
471
  return;
606
472
  }
607
- const useProcessGroup = process.platform !== "win32";
608
- const child = spawn(command, args, {
609
- cwd,
473
+ const limits = DEFAULT_PLUGIN_PACKAGE_INSPECTION_LIMITS;
474
+ const child = spawn("git", [
475
+ "-C",
476
+ checkout,
477
+ "ls-tree",
478
+ "-rlz",
479
+ "-r",
480
+ revision,
481
+ "--",
482
+ subdir,
483
+ ], {
610
484
  stdio: ["ignore", "pipe", "pipe"],
611
- detached: useProcessGroup,
485
+ detached: process.platform !== "win32",
612
486
  });
613
- let settled = false;
614
- let aborting = false;
615
- let childClosed = false;
616
- let escalationTimer;
617
- let hardStopTimer;
618
- let groupPollTimer;
619
- const settle = (callback) => {
620
- if (settled)
621
- return;
622
- settled = true;
623
- signal?.removeEventListener("abort", abort);
624
- if (escalationTimer)
625
- clearTimeout(escalationTimer);
626
- if (hardStopTimer)
627
- clearTimeout(hardStopTimer);
628
- if (groupPollTimer)
629
- clearInterval(groupPollTimer);
630
- callback();
631
- };
632
- const rejectAborted = () => settle(() => reject(new Error("plugin installation aborted", {
633
- cause: signal?.reason,
634
- })));
635
- const processGroupAlive = () => {
636
- if (!useProcessGroup || child.pid === undefined)
637
- return !childClosed;
487
+ let pending = Buffer.alloc(0);
488
+ let stderr = "";
489
+ let failure;
490
+ let entries = 1;
491
+ let totalBytes = 0;
492
+ const directories = new Set([""]);
493
+ const terminate = () => {
638
494
  try {
639
- process.kill(-child.pid, 0);
640
- return true;
495
+ if (process.platform !== "win32" && child.pid)
496
+ process.kill(-child.pid, "SIGKILL");
497
+ else
498
+ child.kill("SIGKILL");
641
499
  }
642
- catch (error) {
643
- return error.code !== "ESRCH";
500
+ catch {
501
+ // The command has already exited.
644
502
  }
645
503
  };
646
- const signalProcessTree = (processSignal) => {
647
- if (useProcessGroup && child.pid !== undefined) {
648
- try {
649
- process.kill(-child.pid, processSignal);
650
- return;
651
- }
652
- catch (error) {
653
- if (error.code !== "ESRCH") {
654
- try {
655
- child.kill(processSignal);
656
- }
657
- catch {
658
- // The bounded hard-stop path below still resolves cancellation.
659
- }
660
- }
661
- return;
504
+ const fail = (error) => {
505
+ failure ??= error;
506
+ terminate();
507
+ };
508
+ const inspectRecord = (line) => {
509
+ if (failure)
510
+ return;
511
+ const tab = line.indexOf(0x09);
512
+ if (tab < 0) {
513
+ fail(new Error("git ls-tree returned an invalid package entry"));
514
+ return;
515
+ }
516
+ const metadata = line.subarray(0, tab).toString("utf8");
517
+ const pathBytes = line.subarray(tab + 1);
518
+ const path = pathBytes.toString("utf8");
519
+ const match = /^([0-7]{6}) ([a-z]+) [0-9a-f]{40,64} ([0-9]+|-)$/.exec(metadata);
520
+ if (!match || !path || !Buffer.from(path, "utf8").equals(pathBytes)) {
521
+ fail(new Error("git ls-tree returned an invalid package entry"));
522
+ return;
523
+ }
524
+ const prefix = subdir === "." ? "" : `${subdir}/`;
525
+ const relativePath = prefix && path.startsWith(prefix) ? path.slice(prefix.length) : path;
526
+ const segments = relativePath.split("/");
527
+ if (match[1] === "120000" && segments.includes("node_modules"))
528
+ return;
529
+ if (match[1] !== "100644" && match[1] !== "100755") {
530
+ fail(new Error(`plugin package contains an unsupported Git entry: ${path}`));
531
+ return;
532
+ }
533
+ if (match[2] !== "blob" || match[3] === "-") {
534
+ fail(new Error(`plugin package contains a Git submodule or special entry: ${path}`));
535
+ return;
536
+ }
537
+ if (!relativePath || segments.some((segment) => !segment || segment === "." || segment === "..")) {
538
+ fail(new Error(`git package contains an invalid path: ${path}`));
539
+ return;
540
+ }
541
+ if (segments.length > limits.maxDepth) {
542
+ fail(new Error(`plugin package exceeds maximum depth ${limits.maxDepth}`));
543
+ return;
544
+ }
545
+ let parent = "";
546
+ for (const segment of segments.slice(0, -1)) {
547
+ parent = parent ? `${parent}/${segment}` : segment;
548
+ if (!directories.has(parent)) {
549
+ directories.add(parent);
550
+ entries += 1;
662
551
  }
663
552
  }
664
- try {
665
- child.kill(processSignal);
553
+ entries += 1;
554
+ if (entries > limits.maxEntries) {
555
+ fail(new Error(`plugin package exceeds ${limits.maxEntries} entries`));
556
+ return;
666
557
  }
667
- catch {
668
- // A concurrently exited child is already stopped.
558
+ const size = Number(match[3]);
559
+ if (!Number.isSafeInteger(size) || size > limits.maxFileBytes) {
560
+ fail(new Error(`plugin package file exceeds ${limits.maxFileBytes} bytes: ${path}`));
561
+ return;
562
+ }
563
+ totalBytes += size;
564
+ if (totalBytes > limits.maxTotalBytes) {
565
+ fail(new Error(`plugin package exceeds ${limits.maxTotalBytes} total bytes`));
669
566
  }
670
567
  };
671
- const finishWhenStopped = () => {
672
- if (!aborting || settled)
568
+ const abort = () => fail(new Error("plugin installation aborted"));
569
+ signal?.addEventListener("abort", abort, { once: true });
570
+ child.stdout?.on("data", (chunk) => {
571
+ if (failure)
673
572
  return;
674
- if (childClosed && !processGroupAlive())
675
- rejectAborted();
573
+ pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
574
+ for (;;) {
575
+ const separator = pending.indexOf(0);
576
+ if (separator < 0)
577
+ break;
578
+ inspectRecord(pending.subarray(0, separator));
579
+ pending = pending.subarray(separator + 1);
580
+ }
581
+ if (pending.length > MAX_METADATA_BYTES) {
582
+ fail(new Error("git package path exceeds 1 MiB"));
583
+ }
584
+ });
585
+ child.stderr?.on("data", (chunk) => {
586
+ if (stderr.length < 64 * 1024)
587
+ stderr += chunk.toString("utf8");
588
+ });
589
+ child.once("error", (error) => fail(error));
590
+ child.once("close", (code) => {
591
+ signal?.removeEventListener("abort", abort);
592
+ if (!failure && pending.length > 0) {
593
+ failure = new Error("git ls-tree returned a truncated package entry");
594
+ }
595
+ if (failure)
596
+ reject(failure);
597
+ else if (code !== 0) {
598
+ reject(new Error(`git ls-tree exited with code ${code ?? "null"}${stderr ? `: ${stderr.trim()}` : ""}`));
599
+ }
600
+ else {
601
+ resolveDone();
602
+ }
603
+ });
604
+ });
605
+ }
606
+ export function runPluginPackageCommand(command, args, onProgress, options = {}) {
607
+ return runCommand(command, args, onProgress, options).then(() => undefined);
608
+ }
609
+ async function capturePluginPackageCommand(command, args, options) {
610
+ let output = "";
611
+ await runCommand(command, args, (line) => {
612
+ output += `${line}\n`;
613
+ }, options);
614
+ const value = output.trim();
615
+ if (!value)
616
+ throw new Error(`${command} produced no output`);
617
+ return value;
618
+ }
619
+ function runCommand(command, args, onProgress, options) {
620
+ return new Promise((resolveDone, reject) => {
621
+ const graceMs = options.abortGraceMs ?? DEFAULT_COMMAND_ABORT_GRACE_MS;
622
+ if (options.signal?.aborted) {
623
+ reject(new Error("plugin installation aborted"));
624
+ return;
625
+ }
626
+ const child = spawn(command, args, {
627
+ cwd: options.cwd,
628
+ stdio: ["ignore", "pipe", "pipe"],
629
+ detached: process.platform !== "win32",
630
+ });
631
+ let finished = false;
632
+ const finish = (callback) => {
633
+ if (finished)
634
+ return;
635
+ finished = true;
636
+ options.signal?.removeEventListener("abort", abort);
637
+ callback();
676
638
  };
677
639
  const abort = () => {
678
- if (aborting || settled)
679
- return;
680
- aborting = true;
681
- signalProcessTree("SIGTERM");
682
- groupPollTimer = setInterval(finishWhenStopped, 25);
683
- escalationTimer = setTimeout(() => {
684
- signalProcessTree("SIGKILL");
685
- finishWhenStopped();
686
- }, abortGraceMs);
687
- hardStopTimer = setTimeout(rejectAborted, abortGraceMs * 2);
688
- finishWhenStopped();
640
+ try {
641
+ if (process.platform !== "win32" && child.pid)
642
+ process.kill(-child.pid, "SIGTERM");
643
+ else
644
+ child.kill("SIGTERM");
645
+ }
646
+ catch {
647
+ // Child already exited.
648
+ }
649
+ const timer = setTimeout(() => {
650
+ try {
651
+ if (process.platform !== "win32" && child.pid)
652
+ process.kill(-child.pid, "SIGKILL");
653
+ else
654
+ child.kill("SIGKILL");
655
+ }
656
+ catch {
657
+ // Child already exited.
658
+ }
659
+ }, graceMs);
660
+ timer.unref?.();
689
661
  };
690
- signal?.addEventListener("abort", abort, { once: true });
691
- if (signal?.aborted)
692
- abort();
693
- const onData = (buffer) => {
694
- for (const line of buffer.toString().split(/\r?\n/)) {
662
+ options.signal?.addEventListener("abort", abort, { once: true });
663
+ const forward = (chunk) => {
664
+ for (const line of chunk.toString("utf8").split(/\r?\n/)) {
695
665
  if (line.trim())
696
666
  onProgress(line);
697
667
  }
698
668
  };
699
- child.stdout?.on("data", onData);
700
- child.stderr?.on("data", onData);
701
- child.on("error", (error) => {
702
- if (aborting)
703
- rejectAborted();
669
+ child.stdout?.on("data", forward);
670
+ child.stderr?.on("data", forward);
671
+ child.once("error", (error) => finish(() => reject(error)));
672
+ child.once("close", (code) => finish(() => {
673
+ if (options.signal?.aborted)
674
+ reject(new Error("plugin installation aborted"));
675
+ else if (code === 0)
676
+ resolveDone();
704
677
  else
705
- settle(() => reject(error));
706
- });
707
- child.on("close", (code) => {
708
- childClosed = true;
709
- if (aborting || signal?.aborted) {
710
- if (!aborting)
711
- abort();
712
- if (!useProcessGroup)
713
- rejectAborted();
714
- else
715
- finishWhenStopped();
716
- return;
717
- }
718
- settle(() => {
719
- if (code === 0)
720
- resolveDone();
721
- else
722
- reject(new Error(`${command} exited with code ${code ?? "null"}`));
723
- });
724
- });
678
+ reject(new Error(`${command} exited with code ${code ?? "null"}`));
679
+ }));
725
680
  });
726
681
  }
727
- function isRecord(value) {
728
- return typeof value === "object" && value !== null && !Array.isArray(value);
729
- }
730
- function messageOf(error) {
731
- return error instanceof Error ? error.message : String(error);
732
- }