@crouter/api 0.3.387 → 0.3.389

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 (131) hide show
  1. package/dist/api/__tests__/integration/client.test.js +97 -0
  2. package/dist/api/client.d.ts +7 -0
  3. package/dist/api/client.js +40 -21
  4. package/dist/api/dto/config.d.ts +11 -1
  5. package/dist/core/asset-root.d.ts +7 -0
  6. package/dist/core/asset-root.js +18 -0
  7. package/dist/core/canvas/boot-id.d.ts +6 -0
  8. package/dist/core/canvas/boot-id.js +26 -0
  9. package/dist/core/canvas/paths.d.ts +72 -0
  10. package/dist/core/canvas/paths.js +163 -0
  11. package/dist/core/canvas/pid.d.ts +391 -0
  12. package/dist/core/canvas/pid.js +948 -0
  13. package/dist/core/command-plugins/bundle.d.ts +149 -0
  14. package/dist/core/command-plugins/bundle.js +588 -0
  15. package/dist/core/command-plugins/endpoint.d.ts +24 -0
  16. package/dist/core/command-plugins/endpoint.js +51 -0
  17. package/dist/core/config.d.ts +233 -0
  18. package/dist/core/config.js +1120 -0
  19. package/dist/core/env-name.d.ts +6 -0
  20. package/dist/core/env-name.js +9 -0
  21. package/dist/core/errors.d.ts +38 -0
  22. package/dist/core/errors.js +90 -0
  23. package/dist/core/events/emit.d.ts +6 -0
  24. package/dist/core/events/emit.js +42 -0
  25. package/dist/core/events/envelope.d.ts +2 -0
  26. package/dist/core/events/envelope.js +84 -0
  27. package/dist/core/events/errors.d.ts +4 -0
  28. package/dist/core/events/errors.js +69 -0
  29. package/dist/core/events/operation-id.d.ts +4 -0
  30. package/dist/core/events/operation-id.js +24 -0
  31. package/dist/core/events/serialize.d.ts +4 -0
  32. package/dist/core/events/serialize.js +199 -0
  33. package/dist/core/events/source.d.ts +16 -0
  34. package/dist/core/events/source.js +31 -0
  35. package/dist/core/events/types.d.ts +68 -0
  36. package/dist/core/events/types.js +11 -0
  37. package/dist/core/exclusive-lock.d.ts +34 -0
  38. package/dist/core/exclusive-lock.js +197 -0
  39. package/dist/core/fs-utils.d.ts +44 -0
  40. package/dist/core/fs-utils.js +208 -0
  41. package/dist/core/help.d.ts +309 -0
  42. package/dist/core/help.js +406 -0
  43. package/dist/core/human/page-catalog.d.ts +57 -0
  44. package/dist/core/human/page-catalog.js +172 -0
  45. package/dist/core/installed-plugins.d.ts +2 -0
  46. package/dist/core/installed-plugins.js +79 -0
  47. package/dist/core/io.d.ts +122 -0
  48. package/dist/core/io.js +373 -0
  49. package/dist/core/keybindings/attach-control.d.ts +49 -0
  50. package/dist/core/keybindings/attach-control.js +42 -0
  51. package/dist/core/keybindings/catalog.d.ts +18 -0
  52. package/dist/core/keybindings/catalog.js +257 -0
  53. package/dist/core/keybindings/types.d.ts +42 -0
  54. package/dist/core/keybindings/types.js +1 -0
  55. package/dist/core/layout.d.ts +26 -0
  56. package/dist/core/layout.js +94 -0
  57. package/dist/core/locked-file.d.ts +27 -0
  58. package/dist/core/locked-file.js +118 -0
  59. package/dist/core/log.d.ts +9 -0
  60. package/dist/core/log.js +89 -0
  61. package/dist/core/manifest.d.ts +5 -0
  62. package/dist/core/manifest.js +15 -0
  63. package/dist/core/plugin-env.d.ts +8 -0
  64. package/dist/core/plugin-env.js +31 -0
  65. package/dist/core/plugin-extensions.d.ts +29 -0
  66. package/dist/core/plugin-extensions.js +191 -0
  67. package/dist/core/plugin-swap-lock.d.ts +9 -0
  68. package/dist/core/plugin-swap-lock.js +31 -0
  69. package/dist/core/preview-result-path.d.ts +4 -0
  70. package/dist/core/preview-result-path.js +26 -0
  71. package/dist/core/profiles/env-store.d.ts +22 -0
  72. package/dist/core/profiles/env-store.js +163 -0
  73. package/dist/core/profiles/fuzzy-match.d.ts +19 -0
  74. package/dist/core/profiles/fuzzy-match.js +92 -0
  75. package/dist/core/profiles/manifest.d.ts +120 -0
  76. package/dist/core/profiles/manifest.js +529 -0
  77. package/dist/core/rate-limit-scope.d.ts +25 -0
  78. package/dist/core/rate-limit-scope.js +64 -0
  79. package/dist/core/render.d.ts +12 -0
  80. package/dist/core/render.js +138 -0
  81. package/dist/core/resolver.d.ts +14 -0
  82. package/dist/core/resolver.js +111 -0
  83. package/dist/core/runtime/branded-host.d.ts +25 -0
  84. package/dist/core/runtime/branded-host.js +264 -0
  85. package/dist/core/runtime/broker/daemon-ops.d.ts +65 -0
  86. package/dist/core/runtime/broker/daemon-ops.js +177 -0
  87. package/dist/core/runtime/broker/signal-stream.d.ts +30 -0
  88. package/dist/core/runtime/broker/signal-stream.js +149 -0
  89. package/dist/core/scope.d.ts +32 -0
  90. package/dist/core/scope.js +184 -0
  91. package/dist/core/scoped-state/db.d.ts +17 -0
  92. package/dist/core/scoped-state/db.js +247 -0
  93. package/dist/core/scoped-state/migrate.d.ts +8 -0
  94. package/dist/core/scoped-state/migrate.js +187 -0
  95. package/dist/core/scoped-state/paths.d.ts +9 -0
  96. package/dist/core/scoped-state/paths.js +27 -0
  97. package/dist/core/scoped-state/profiles.d.ts +27 -0
  98. package/dist/core/scoped-state/profiles.js +93 -0
  99. package/dist/core/scoped-state/providers.d.ts +24 -0
  100. package/dist/core/scoped-state/providers.js +19 -0
  101. package/dist/core/scoped-state/schema.d.ts +6 -0
  102. package/dist/core/scoped-state/schema.js +43 -0
  103. package/dist/core/scoped-state/settings.d.ts +28 -0
  104. package/dist/core/scoped-state/settings.js +83 -0
  105. package/dist/core/spaces/open-beneath.d.ts +71 -0
  106. package/dist/core/spaces/open-beneath.js +581 -0
  107. package/dist/core/sqlite-statements.d.ts +4 -0
  108. package/dist/core/sqlite-statements.js +17 -0
  109. package/dist/core/subscription-state.d.ts +121 -0
  110. package/dist/core/subscription-state.js +287 -0
  111. package/dist/core/user-settings.d.ts +377 -0
  112. package/dist/core/user-settings.js +458 -0
  113. package/dist/daemon/broker-signals/bus.d.ts +30 -0
  114. package/dist/daemon/broker-signals/bus.js +87 -0
  115. package/dist/daemon/manage.d.ts +176 -0
  116. package/dist/daemon/manage.js +664 -0
  117. package/dist/daemon/pidfile.d.ts +8 -0
  118. package/dist/daemon/pidfile.js +37 -0
  119. package/dist/daemon/startup-policy.d.ts +1 -0
  120. package/dist/daemon/startup-policy.js +1 -0
  121. package/dist/native/linux.d.ts +29 -0
  122. package/dist/native/linux.js +20 -0
  123. package/dist/shared/env.d.ts +116 -0
  124. package/dist/shared/env.js +271 -0
  125. package/dist/shared/inbox-entry-body.d.ts +22 -0
  126. package/dist/shared/inbox-entry-body.js +116 -0
  127. package/dist/shared/working-activity.d.ts +9 -0
  128. package/dist/shared/working-activity.js +27 -0
  129. package/dist/types.d.ts +562 -0
  130. package/dist/types.js +186 -0
  131. package/package.json +1 -1
@@ -0,0 +1,588 @@
1
+ import { Parser } from 'tar';
2
+ import { createHash } from 'node:crypto';
3
+ import { chmodSync, renameSync, writeFileSync } from 'node:fs';
4
+ import { dirname, join, resolve, sep } from 'node:path';
5
+ import { validateCommandManifest } from '../../api/command-manifest/manifest.js';
6
+ import { ensureScopeInitialized, invalidPluginKindsReasons, readConfig, updateConfig, updateState } from '../config.js';
7
+ import { general } from '../errors.js';
8
+ import { atomicWriteJson, ensureDir, nowIso, pathExists, removePath } from '../fs-utils.js';
9
+ import { invalidPageComponentsReasons, normalizePageComponents } from '../human/page-catalog.js';
10
+ import { validateMemoryExtensionDeclarations } from '../plugin-extensions.js';
11
+ import { validateHttpPluginTransport } from './endpoint.js';
12
+ /** A capability provider id or tool group id: contract 1's `<id>` grammar. */
13
+ const PROVIDER_ID = /^[A-Za-z0-9_-]{1,64}$/;
14
+ const PROVIDER_KEYS = new Set(['id', 'major', 'groups']);
15
+ const PROVIDER_GROUP_KEYS = new Set(['group', 'consent_line', 'rate', 'who', 'allowlist']);
16
+ const PROVIDER_WHO = new Set(['own', 'user', 'allowlist', 'any']);
17
+ const BUNDLE_KEYS = new Set(['bundleVersion', 'kinds', 'page_components', 'memory_extensions', 'version', 'transport', 'provider']);
18
+ function issue(code, message, received, expected, next, path) {
19
+ return { code, message, received, expected, next, ...(path !== undefined ? { path } : {}) };
20
+ }
21
+ function bundleInvalid(message, received, expected, next, path) {
22
+ return issue('plugin_bundle_invalid', message, received, expected, next, path);
23
+ }
24
+ function archiveEnvelopeIssue(archive) {
25
+ // tar v7's Parser always sniffs gzip, even when passed gzip: false. Reject
26
+ // its unambiguous magic bytes before parsing so archive plugins stay tar-only.
27
+ if (archive[0] === 0x1f && archive[1] === 0x8b) {
28
+ return bundleInvalid('bundle archive must be uncompressed tar data', 'gzip-compressed archive', 'an uncompressed tar archive', 'Serve the archive without compression.');
29
+ }
30
+ if (archive.length === 0 || archive.length % 512 !== 0) {
31
+ return bundleInvalid('bundle archive must be a non-empty whole number of 512-byte tar blocks', `${archive.length} bytes`, 'a non-empty tar stream with a 512-byte block length', 'Serve one complete uncompressed tar archive.');
32
+ }
33
+ if (archive.length < 1024 || !archive.subarray(-1024).every((byte) => byte === 0)) {
34
+ return bundleInvalid('bundle archive is missing the required two zero-block terminator', 'archive tail is not 1024 zero bytes', 'a complete tar stream ending in two 512-byte zero blocks', 'Serve the complete uncompressed tar archive.');
35
+ }
36
+ return undefined;
37
+ }
38
+ function validateMemberName(path, type) {
39
+ if (path.length === 0 || path.includes('\u0000') || path.includes('\\') || path.startsWith('/') || /^[A-Za-z]:($|\/)/.test(path)) {
40
+ return bundleInvalid('bundle member path must be a safe relative POSIX path', path, 'a relative path without absolute, drive-letter, backslash, or NUL forms', 'Use a safe relative POSIX member path.', path);
41
+ }
42
+ if (type === 'file' && path.endsWith('/')) {
43
+ return bundleInvalid('only directory members may have a trailing slash', path, 'a regular-file path without a trailing slash', 'Emit this member as a regular file path.', path);
44
+ }
45
+ const segments = (type === 'directory' && path.endsWith('/') ? path.slice(0, -1) : path).split('/');
46
+ if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
47
+ return bundleInvalid('bundle member path contains an unsafe segment', path, 'non-empty segments other than . or ..', 'Use a safe relative POSIX member path.', path);
48
+ }
49
+ return undefined;
50
+ }
51
+ function validateMemberShape(path, type) {
52
+ if (type === 'file') {
53
+ if (path === 'bundle.json' || path === 'commands.json' || path === 'hooks.json' || (path.startsWith('memory/') && path.endsWith('.md')) || path.startsWith('hooks/'))
54
+ return undefined;
55
+ return bundleInvalid('bundle contains an unsupported file member', path, 'bundle.json, commands.json, hooks.json, memory/**/*.md, or hooks/**', 'Remove unsupported members from the archive.', path);
56
+ }
57
+ if (path === 'memory' || path.startsWith('memory/') || path === 'hooks' || path.startsWith('hooks/'))
58
+ return undefined;
59
+ return bundleInvalid('bundle contains an unsupported directory member', path, 'memory or hooks, or a directory below either subtree', 'Remove unsupported directory members from the archive.', path);
60
+ }
61
+ /** A member path as it will exist on disk: directories carry no trailing slash. */
62
+ function materializedPath(member) {
63
+ return member.type === 'directory' && member.path.endsWith('/') ? member.path.slice(0, -1) : member.path;
64
+ }
65
+ /** Reject member sets that cannot become one filesystem tree — a path claimed as
66
+ * both a file and a directory, the same path twice after directory
67
+ * normalization, or a file that another member nests beneath. */
68
+ function validateMemberTree(members) {
69
+ const claimed = new Map();
70
+ for (const member of members) {
71
+ const path = materializedPath(member);
72
+ const existing = claimed.get(path);
73
+ if (existing !== undefined) {
74
+ return bundleInvalid(existing === member.type
75
+ ? 'bundle contains a duplicate member'
76
+ : 'bundle claims one path as both a file and a directory', `${path} (${existing} and ${member.type})`, 'each materialized path exactly once, with one type', 'Emit each archive path once, as a single member type.', path);
77
+ }
78
+ claimed.set(path, member.type);
79
+ }
80
+ for (const [path, type] of claimed) {
81
+ if (type !== 'file')
82
+ continue;
83
+ for (const other of claimed.keys()) {
84
+ if (other.startsWith(`${path}/`)) {
85
+ return bundleInvalid('bundle nests a member beneath a regular-file path', `${other} beneath file ${path}`, 'members nested only beneath directory paths', 'Emit the parent path as a directory or remove the nested member.', other);
86
+ }
87
+ }
88
+ }
89
+ return undefined;
90
+ }
91
+ async function parseArchive(archive) {
92
+ const members = [];
93
+ const seen = new Set();
94
+ let memberIssue;
95
+ await new Promise((resolve, reject) => {
96
+ const parser = new Parser({
97
+ strict: true,
98
+ maxMetaEntrySize: Number.MAX_SAFE_INTEGER,
99
+ gzip: false,
100
+ brotli: false,
101
+ zstd: false,
102
+ onReadEntry: (entry) => {
103
+ const path = entry.path;
104
+ const type = entry.type === 'File' || entry.type === 'OldFile'
105
+ ? 'file'
106
+ : entry.type === 'Directory'
107
+ ? 'directory'
108
+ : undefined;
109
+ if (type === undefined || entry.unsupported || entry.ignore || entry.linkpath !== undefined) {
110
+ memberIssue ??= bundleInvalid('bundle member must be a regular file or directory without a link target', `${path} (${entry.type})`, 'a regular file or directory member without link metadata', 'Remove links and unsupported member types from the archive.', path);
111
+ entry.resume();
112
+ return;
113
+ }
114
+ memberIssue ??= validateMemberName(path, type);
115
+ memberIssue ??= validateMemberShape(path, type);
116
+ if (seen.has(path)) {
117
+ memberIssue ??= bundleInvalid('bundle contains a duplicate member', path, 'each member path exactly once', 'Emit each archive member only once.', path);
118
+ }
119
+ seen.add(path);
120
+ const chunks = [];
121
+ entry.on('data', (chunk) => chunks.push(chunk));
122
+ entry.on('end', () => {
123
+ if (memberIssue === undefined)
124
+ members.push({ path, type, bytes: new Uint8Array(Buffer.concat(chunks)) });
125
+ });
126
+ },
127
+ });
128
+ parser.on('ignoredEntry', (entry) => {
129
+ const path = entry.path;
130
+ memberIssue ??= bundleInvalid('bundle contains an ignored or unsupported archive member', `${path} (${entry.type})`, 'only supported regular file or directory members', 'Remove ignored or unsupported members from the archive.', path);
131
+ entry.resume();
132
+ });
133
+ parser.once('error', reject);
134
+ parser.once('end', resolve);
135
+ try {
136
+ parser.end(Buffer.from(archive));
137
+ }
138
+ catch (error) {
139
+ reject(error);
140
+ }
141
+ }).catch((error) => {
142
+ memberIssue ??= bundleInvalid('bundle archive could not be parsed', error instanceof Error ? error.message : String(error), 'a valid complete uncompressed tar archive', 'Fix the archive format and serve it again.');
143
+ });
144
+ return memberIssue === undefined ? { members } : { issue: memberIssue };
145
+ }
146
+ function isPositiveInt(v) {
147
+ return typeof v === 'number' && Number.isInteger(v) && v >= 1;
148
+ }
149
+ /** Validate the `provider` block's own shape (cross-checks against the
150
+ * commands and the transport happen once the whole bundle is parsed). */
151
+ function parseProviderBlock(raw) {
152
+ const bad = (message, received, expected, path) => ({
153
+ issue: bundleInvalid(message, received, expected, 'Fix the provider block in bundle.json.', `bundle.json.${path}`),
154
+ });
155
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
156
+ return bad('bundle.json provider must be an object', JSON.stringify(raw), '{ id, major, groups }', 'provider');
157
+ }
158
+ const p = raw;
159
+ const unknown = Object.keys(p).filter((key) => !PROVIDER_KEYS.has(key));
160
+ if (unknown.length > 0)
161
+ return bad('bundle.json provider has unknown keys', unknown.join(', '), 'only: id, major, groups', 'provider');
162
+ if (typeof p['id'] !== 'string' || !PROVIDER_ID.test(p['id'])) {
163
+ return bad('provider id must match [A-Za-z0-9_-]{1,64}', JSON.stringify(p['id']), 'a provider id of 1-64 letters, digits, "_" or "-"', 'provider.id');
164
+ }
165
+ if (!isPositiveInt(p['major'])) {
166
+ return bad('provider major must be an integer of at least 1', JSON.stringify(p['major']), 'the served major as an integer >= 1', 'provider.major');
167
+ }
168
+ const groups = p['groups'];
169
+ if (!Array.isArray(groups) || groups.length === 0) {
170
+ return bad('provider groups must be a non-empty array', JSON.stringify(groups), 'at least one { group, consent_line, rate?, who, allowlist }', 'provider.groups');
171
+ }
172
+ const out = [];
173
+ const seen = new Set();
174
+ for (let i = 0; i < groups.length; i++) {
175
+ const gp = `provider.groups[${i}]`;
176
+ const g = groups[i];
177
+ if (typeof g !== 'object' || g === null || Array.isArray(g))
178
+ return bad('provider group must be an object', JSON.stringify(g), '{ group, consent_line, rate?, who, allowlist }', gp);
179
+ const r = g;
180
+ const extra = Object.keys(r).filter((key) => !PROVIDER_GROUP_KEYS.has(key));
181
+ if (extra.length > 0)
182
+ return bad('provider group has unknown keys', extra.join(', '), 'only: group, consent_line, rate, who, allowlist', gp);
183
+ if (typeof r['group'] !== 'string' || !PROVIDER_ID.test(r['group'])) {
184
+ return bad('provider group id must match [A-Za-z0-9_-]{1,64}', JSON.stringify(r['group']), 'a group id of 1-64 letters, digits, "_" or "-"', `${gp}.group`);
185
+ }
186
+ if (seen.has(r['group']))
187
+ return bad('provider group ids must be unique', r['group'], 'each group declared once', `${gp}.group`);
188
+ seen.add(r['group']);
189
+ if (typeof r['consent_line'] !== 'string' || r['consent_line'].trim().length === 0) {
190
+ return bad('provider group consent_line must be a non-empty string', JSON.stringify(r['consent_line']), 'the consent line shown for this group', `${gp}.consent_line`);
191
+ }
192
+ const rate = r['rate'];
193
+ if (rate !== undefined && rate !== null && (typeof rate !== 'number' || !Number.isFinite(rate) || rate < 0)) {
194
+ return bad('provider group rate must be a non-negative number or null', JSON.stringify(rate), 'a finite number >= 0, null, or omitted', `${gp}.rate`);
195
+ }
196
+ if (typeof r['who'] !== 'string' || !PROVIDER_WHO.has(r['who'])) {
197
+ return bad('provider group who must be own|user|allowlist|any', JSON.stringify(r['who']), 'own | user | allowlist | any', `${gp}.who`);
198
+ }
199
+ const allowlist = r['allowlist'];
200
+ if (!Array.isArray(allowlist) || !allowlist.every((entry) => typeof entry === 'string' && entry.length > 0)) {
201
+ return bad('provider group allowlist must be an array of non-empty strings', JSON.stringify(allowlist), 'an array of app principals ([] unless who is allowlist)', `${gp}.allowlist`);
202
+ }
203
+ if (allowlist.length > 0 && r['who'] !== 'allowlist') {
204
+ return bad('provider group allowlist must be empty unless who is allowlist', `who ${r['who']}`, 'who: "allowlist", or allowlist: []', `${gp}.allowlist`);
205
+ }
206
+ out.push({
207
+ group: r['group'],
208
+ consent_line: r['consent_line'],
209
+ ...(rate !== undefined ? { rate: rate } : {}),
210
+ who: r['who'],
211
+ allowlist: allowlist,
212
+ });
213
+ }
214
+ return { provider: { id: p['id'], major: p['major'], groups: out } };
215
+ }
216
+ /** Every leaf of the validated command tree with its space-joined path. */
217
+ function collectLeaves(commands) {
218
+ const out = [];
219
+ const walk = (node, prefix) => {
220
+ const path = [...prefix, node.name];
221
+ if (node.kind === 'leaf')
222
+ out.push({ path: path.join(' '), leaf: node });
223
+ else
224
+ for (const child of node.children)
225
+ walk(child, path);
226
+ };
227
+ for (const root of commands.roots)
228
+ walk(root, []);
229
+ for (const mount of commands.coreMounts)
230
+ walk(mount.node, mount.parent);
231
+ return out;
232
+ }
233
+ /** Cross-check a provider bundle: it names its transport without authEnv,
234
+ * every leaf names a declared group, and no leaf streams. Each refusal
235
+ * names the leaf. */
236
+ function providerBundleIssue(metadata, commands) {
237
+ const provider = metadata.provider;
238
+ if (provider === undefined)
239
+ return undefined;
240
+ if (metadata.transport === undefined) {
241
+ return bundleInvalid('a provider bundle must declare its transport', '(missing)', 'transport { kind: "http", endpoint } in bundle.json', 'Add the provider endpoint as bundle.json transport.', 'bundle.json.transport');
242
+ }
243
+ if (metadata.transport.authEnv !== undefined) {
244
+ return bundleInvalid('a provider bundle must not declare authEnv', metadata.transport.authEnv, 'no authEnv: the runtime presents its own outbound token', 'Remove transport.authEnv from bundle.json.', 'bundle.json.transport.authEnv');
245
+ }
246
+ const declared = new Set(provider.groups.map((g) => g.group));
247
+ for (const { path, leaf } of collectLeaves(commands)) {
248
+ const group = 'group' in leaf ? leaf.group : undefined;
249
+ if (group === undefined) {
250
+ return bundleInvalid(`provider leaf "${path}" has no group`, path, 'a group on every leaf of a provider bundle', `Add a group to leaf "${path}".`, `commands.json:${path}.group`);
251
+ }
252
+ if (!declared.has(group)) {
253
+ return bundleInvalid(`provider leaf "${path}" names undeclared group "${group}"`, group, `one of: ${[...declared].join(', ')}`, `Declare group "${group}" in the provider block, or move leaf "${path}" to a declared group.`, `commands.json:${path}.group`);
254
+ }
255
+ if ('rest' in leaf && leaf.rest.streaming === true) {
256
+ return bundleInvalid(`provider leaf "${path}" streams`, 'rest.streaming: true', 'no streaming leaves in a provider bundle', `Make leaf "${path}" return one response.`, `commands.json:${path}.rest.streaming`);
257
+ }
258
+ }
259
+ return undefined;
260
+ }
261
+ function parseBundleMetadata(bytes) {
262
+ let raw;
263
+ try {
264
+ raw = JSON.parse(Buffer.from(bytes).toString('utf8'));
265
+ }
266
+ catch {
267
+ return { issue: bundleInvalid('bundle.json is not valid JSON', 'invalid JSON', '{"bundleVersion":1}', 'Write valid bundle metadata.', 'bundle.json') };
268
+ }
269
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
270
+ return {
271
+ issue: bundleInvalid('bundle.json must be a JSON object', JSON.stringify(raw), 'an object with bundleVersion set to 1 and optionally kinds', 'Set bundle.json to {"bundleVersion":1}.', 'bundle.json'),
272
+ };
273
+ }
274
+ const record = raw;
275
+ const unknownKeys = Object.keys(record).filter((key) => !BUNDLE_KEYS.has(key));
276
+ if (record['bundleVersion'] !== 1 || unknownKeys.length > 0) {
277
+ return {
278
+ issue: bundleInvalid('bundle.json must contain bundleVersion 1 and at most version, transport, provider, kinds, page_components, and memory_extensions', JSON.stringify(raw), 'an object with bundleVersion set to 1 and optional version, transport, provider, kinds, page_components, and memory_extensions', 'Set bundle.json to {"bundleVersion":1} plus optional contribution blocks.', 'bundle.json'),
279
+ };
280
+ }
281
+ // Install-time strictness: a bad block fails the install loudly here, because
282
+ // the read side (`mergeKinds`) drops invalid kind entries silently and would
283
+ // otherwise ship a kind that never registers.
284
+ if (record['kinds'] !== undefined) {
285
+ const reasons = invalidPluginKindsReasons(record['kinds']);
286
+ if (reasons.length > 0) {
287
+ return {
288
+ issue: bundleInvalid('bundle.json kinds block is invalid', reasons.join('; '), 'each entry a full KindConfig (whenToUse required) or a sparse patch of launch fields', 'Fix the kinds entries in bundle.json.', 'bundle.json'),
289
+ };
290
+ }
291
+ }
292
+ if (record['page_components'] !== undefined) {
293
+ const reasons = invalidPageComponentsReasons(record['page_components']);
294
+ if (reasons.length > 0) {
295
+ return {
296
+ issue: bundleInvalid('bundle.json page_components block is invalid', reasons.join('; '), 'an array of page component registrations, each a kind string or {kind, description?, useWhen?, doc?, display?}', 'Fix the page_components entries in bundle.json.', 'bundle.json'),
297
+ };
298
+ }
299
+ }
300
+ let memoryExtensions;
301
+ if (record['memory_extensions'] !== undefined) {
302
+ try {
303
+ memoryExtensions = validateMemoryExtensionDeclarations(record['memory_extensions']);
304
+ }
305
+ catch (error) {
306
+ return {
307
+ issue: bundleInvalid('bundle.json memory_extensions block is invalid', error instanceof Error ? error.message : String(error), 'a valid memory_extensions declaration object', 'Fix the memory_extensions declarations in bundle.json.', 'bundle.json'),
308
+ };
309
+ }
310
+ }
311
+ if (record['version'] !== undefined && !isPositiveInt(record['version'])) {
312
+ return {
313
+ issue: bundleInvalid('bundle.json version must be an integer of at least 1', JSON.stringify(record['version']), 'the plugin major as an integer >= 1, or omitted', 'Fix version in bundle.json.', 'bundle.json.version'),
314
+ };
315
+ }
316
+ let transport;
317
+ if (record['transport'] !== undefined) {
318
+ const t = validateHttpPluginTransport(record['transport']);
319
+ if (t.transport === undefined) {
320
+ return {
321
+ issue: bundleInvalid('bundle.json transport is invalid', t.errors.join('; '), '{ kind: "http", endpoint, authEnv? }', 'Fix transport in bundle.json.', 'bundle.json.transport'),
322
+ };
323
+ }
324
+ transport = t.transport;
325
+ }
326
+ let provider;
327
+ if (record['provider'] !== undefined) {
328
+ const parsed = parseProviderBlock(record['provider']);
329
+ if (parsed.issue !== undefined)
330
+ return { issue: parsed.issue };
331
+ provider = parsed.provider;
332
+ if (record['version'] !== undefined && record['version'] !== provider.major) {
333
+ return {
334
+ issue: bundleInvalid('bundle.json provider major must equal version', `version ${String(record['version'])}, major ${provider.major}`, 'one major', 'Set provider.major and version to the same integer.', 'bundle.json.provider.major'),
335
+ };
336
+ }
337
+ }
338
+ return {
339
+ ...(record['version'] !== undefined ? { version: record['version'] } : {}),
340
+ ...(transport !== undefined ? { transport } : {}),
341
+ ...(provider !== undefined ? { provider } : {}),
342
+ ...(record['kinds'] !== undefined ? { kinds: record['kinds'] } : {}),
343
+ ...(record['page_components'] !== undefined ? { page_components: normalizePageComponents(record['page_components']) } : {}),
344
+ ...(memoryExtensions !== undefined ? { memory_extensions: memoryExtensions } : {}),
345
+ };
346
+ }
347
+ function parseCommands(bytes, reservedCoreNames, coreCommandPaths, extensibleCoreBranches = new Set()) {
348
+ let raw;
349
+ try {
350
+ raw = JSON.parse(Buffer.from(bytes).toString('utf8'));
351
+ }
352
+ catch {
353
+ return {
354
+ issues: [bundleInvalid('commands.json is not valid JSON', 'invalid JSON', 'a valid command-manifest JSON object', 'Regenerate commands.json.', 'commands.json')],
355
+ };
356
+ }
357
+ const validation = validateCommandManifest(raw, { transport: 'http', reservedCoreNames, extensibleCoreBranches, ...(coreCommandPaths !== undefined ? { coreCommandPaths } : {}) });
358
+ return {
359
+ ...(validation.manifest !== undefined ? { commands: validation.manifest } : {}),
360
+ issues: validation.issues,
361
+ };
362
+ }
363
+ /** Validate a Core-served plugin directory archive entirely in memory. */
364
+ export async function validatePluginBundle(archive, options) {
365
+ const envelope = archiveEnvelopeIssue(archive);
366
+ if (envelope !== undefined)
367
+ return { issues: [envelope] };
368
+ const parsed = await parseArchive(archive);
369
+ if (parsed.issue !== undefined || parsed.members === undefined)
370
+ return { issues: [parsed.issue] };
371
+ const treeIssue = validateMemberTree(parsed.members);
372
+ if (treeIssue !== undefined)
373
+ return { issues: [treeIssue] };
374
+ const bundle = parsed.members.find((member) => member.path === 'bundle.json' && member.type === 'file');
375
+ const commands = parsed.members.find((member) => member.path === 'commands.json' && member.type === 'file');
376
+ if (bundle === undefined || commands === undefined) {
377
+ return {
378
+ issues: [bundleInvalid('bundle must contain exactly one bundle.json and one commands.json regular file', `bundle.json=${bundle !== undefined}, commands.json=${commands !== undefined}`, 'one regular bundle.json and one regular commands.json member', 'Add the required metadata members to the archive.')],
379
+ };
380
+ }
381
+ const hookManifest = parsed.members.find((member) => member.path === 'hooks.json' && member.type === 'file');
382
+ const hookFiles = parsed.members.filter((member) => member.type === 'file' && member.path.startsWith('hooks/'));
383
+ const carriesHooks = hookManifest !== undefined || parsed.members.some((member) => member.path === 'hooks' || member.path.startsWith('hooks/'));
384
+ if (carriesHooks && (hookManifest === undefined || !hookFiles.some((member) => member.path === 'hooks/dispatch'))) {
385
+ return {
386
+ issues: [bundleInvalid('bundle hook payload must contain both hooks.json and hooks/dispatch regular files', `hooks.json=${hookManifest !== undefined}, hooks/dispatch=${hookFiles.some((member) => member.path === 'hooks/dispatch')}`, 'both hooks.json and hooks/dispatch regular files', 'Add both required hook members or remove the hook payload.')],
387
+ };
388
+ }
389
+ const metadata = parseBundleMetadata(bundle.bytes);
390
+ if (metadata.issue !== undefined)
391
+ return { issues: [metadata.issue] };
392
+ const commandValidation = parseCommands(commands.bytes, options.reservedCoreNames, options.coreCommandPaths, options.extensibleCoreBranches);
393
+ if (commandValidation.commands === undefined)
394
+ return { issues: commandValidation.issues };
395
+ const commandsVersion = commandValidation.commands.version;
396
+ if (metadata.version !== undefined && commandsVersion !== undefined && metadata.version !== commandsVersion) {
397
+ return {
398
+ issues: [bundleInvalid('bundle.json version and commands.json version differ', `bundle.json ${metadata.version}, commands.json ${commandsVersion}`, 'one plugin major', 'Emit the same version in bundle.json and commands.json.', 'bundle.json.version')],
399
+ };
400
+ }
401
+ const version = metadata.version ?? commandsVersion;
402
+ if (metadata.provider !== undefined && version === undefined) {
403
+ return {
404
+ issues: [bundleInvalid('a provider bundle requires a plugin version', 'no version in bundle.json or commands.json', `version ${metadata.provider.major}, the provider major`, 'Emit the provider major as the plugin version.', 'bundle.json.version')],
405
+ };
406
+ }
407
+ if (metadata.provider !== undefined && version !== metadata.provider.major) {
408
+ return {
409
+ issues: [bundleInvalid('bundle provider major must equal the plugin version', `version ${version}, major ${metadata.provider.major}`, 'one major', 'Emit the provider major as the plugin version.', 'bundle.json.provider.major')],
410
+ };
411
+ }
412
+ const providerIssue = providerBundleIssue(metadata, commandValidation.commands);
413
+ if (providerIssue !== undefined)
414
+ return { issues: [providerIssue] };
415
+ return {
416
+ bundle: {
417
+ bundleVersion: 1,
418
+ ...(version !== undefined ? { version } : {}),
419
+ ...(metadata.transport !== undefined ? { transport: metadata.transport } : {}),
420
+ ...(metadata.provider !== undefined ? { provider: metadata.provider } : {}),
421
+ ...(metadata.kinds !== undefined ? { kinds: metadata.kinds } : {}),
422
+ ...(metadata.page_components !== undefined ? { page_components: metadata.page_components } : {}),
423
+ ...(metadata.memory_extensions !== undefined ? { memory_extensions: metadata.memory_extensions } : {}),
424
+ commands: commandValidation.commands,
425
+ commandsBytes: commands.bytes,
426
+ directories: parsed.members
427
+ .filter((member) => member.type === 'directory')
428
+ .map((member) => ({ path: member.path })),
429
+ memory: parsed.members
430
+ .filter((member) => member.type === 'file' && member.path.startsWith('memory/'))
431
+ .map((member) => ({ path: member.path, bytes: member.bytes })),
432
+ ...(hookManifest !== undefined ? {
433
+ hooks: {
434
+ manifest: hookManifest.bytes,
435
+ files: hookFiles.map((member) => ({ path: member.path, bytes: member.bytes })),
436
+ },
437
+ } : {}),
438
+ },
439
+ issues: [],
440
+ };
441
+ }
442
+ /** The hash recorded as a bundle package's `version`: the exact archive bytes
443
+ * unpacked at its root. Same bytes, same version — which is what lets a
444
+ * re-sent archive be recognized as the generation already installed. */
445
+ export function bundleVersion(raw) {
446
+ return createHash('sha256').update(raw).digest('hex').slice(0, 12);
447
+ }
448
+ /** Whether `version` is the generation already unpacked at `root`. Both halves
449
+ * matter: the recorded version says the config agrees, and `commands.json`
450
+ * says the tree that config describes is actually on disk. A swap that died
451
+ * between the rename and the config write leaves the second false, and that is
452
+ * exactly the case a no-op must not claim. */
453
+ export function isInstalledGeneration(name, scope, root, version) {
454
+ if (readConfig(scope).plugins[name]?.version !== version)
455
+ return false;
456
+ return pathExists(join(root, 'commands.json'));
457
+ }
458
+ /** The ledger for a check that reached a served archive. `swapped` says whether
459
+ * that archive replaced the package on disk; the validator and the check time
460
+ * are recorded either way, because both describe the bytes now at the root. */
461
+ export function recordCheckedGeneration(scope, name, etag, options) {
462
+ updateState(scope, (state) => {
463
+ if (state.plugins[name] === undefined)
464
+ state.plugins[name] = {};
465
+ if (options.swapped)
466
+ state.plugins[name].last_updated = nowIso();
467
+ state.plugins[name].checked_at = nowIso();
468
+ // The validator names the bytes now unpacked at `root`. A response that
469
+ // carried none clears it, so the next check cannot claim to hold a
470
+ // validator for a package it never received one for.
471
+ if (etag === undefined)
472
+ delete state.plugins[name].etag;
473
+ else
474
+ state.plugins[name].etag = etag;
475
+ });
476
+ }
477
+ function stagedPath(stagingRoot, relative) {
478
+ const path = resolve(stagingRoot, relative);
479
+ if (path === stagingRoot || !path.startsWith(stagingRoot + sep)) {
480
+ throw general(`bundle member path escapes the staging directory: ${relative}`);
481
+ }
482
+ return path;
483
+ }
484
+ function writeStagedBundle(stagingRoot, manifest, bundle) {
485
+ atomicWriteJson(stagedPath(stagingRoot, '.crouter-plugin/plugin.json'), manifest);
486
+ for (const directory of bundle.directories)
487
+ ensureDir(stagedPath(stagingRoot, directory.path));
488
+ writeFileSync(stagedPath(stagingRoot, 'commands.json'), bundle.commandsBytes);
489
+ for (const document of bundle.memory) {
490
+ const target = stagedPath(stagingRoot, document.path);
491
+ ensureDir(resolve(target, '..'));
492
+ writeFileSync(target, document.bytes);
493
+ }
494
+ if (bundle.hooks !== undefined) {
495
+ writeFileSync(stagedPath(stagingRoot, 'hooks.json'), bundle.hooks.manifest);
496
+ for (const file of bundle.hooks.files) {
497
+ const target = stagedPath(stagingRoot, file.path);
498
+ ensureDir(resolve(target, '..'));
499
+ writeFileSync(target, file.bytes);
500
+ }
501
+ chmodSync(stagedPath(stagingRoot, 'hooks/dispatch'), 0o755);
502
+ }
503
+ }
504
+ /**
505
+ * Validate `raw`, stage it beside the package root, swap it in, then record
506
+ * the config version and the state validator — in that order, never the
507
+ * reverse. A failure before the swap leaves the previous package untouched.
508
+ * The caller holds the plugin's swap lock (`bundleSwapLockPath`).
509
+ */
510
+ export async function installBundleArchive(name, raw, options) {
511
+ const { scope, scopeRootPath, root } = options;
512
+ const result = await validatePluginBundle(raw, options.validation);
513
+ if (result.bundle === undefined)
514
+ throw options.invalid(`HTTP plugin bundle for "${name}" is invalid`, result.issues);
515
+ const validated = result.bundle;
516
+ const refusal = options.accept?.(validated);
517
+ if (refusal !== undefined)
518
+ throw options.invalid(`HTTP plugin bundle for "${name}" is invalid`, [bundleInvalid(refusal, name, 'the bundle this install asked for', 'Serve the requested bundle.')]);
519
+ const version = options.version ?? bundleVersion(raw);
520
+ const transport = validated.provider !== undefined && validated.transport !== undefined ? validated.transport : options.transport;
521
+ const manifest = {
522
+ name,
523
+ version,
524
+ commands: 'commands.json',
525
+ transport,
526
+ bundle: options.bundle,
527
+ // Kind-registry contributions declared in the archive's bundle.json,
528
+ // already strictly validated by validatePluginBundle. Copying them here is
529
+ // what registers them: readMergedLaunchConfig reads kinds from installed
530
+ // plugin MANIFESTS, never from bundle.json directly.
531
+ ...(validated.kinds !== undefined ? { kinds: validated.kinds } : {}),
532
+ // Page-component contributions from the archive's bundle.json, likewise
533
+ // already validated. `resolvePageComponents` reads the MANIFEST, so this
534
+ // copy is what makes the components authorable — and dropping it on a
535
+ // later archive that no longer declares them is what retires them.
536
+ ...(validated.page_components !== undefined ? { page_components: validated.page_components } : {}),
537
+ ...(validated.memory_extensions !== undefined ? { memory_extensions: validated.memory_extensions } : {}),
538
+ ...(validated.hooks !== undefined ? { hooks: 'hooks.json', hookExecutable: 'hooks/dispatch' } : {}),
539
+ // A capability provider's block, read by provider composition and calls.
540
+ ...(validated.provider !== undefined ? { provider: validated.provider } : {}),
541
+ };
542
+ const tmpRoot = join(scopeRootPath, 'tmp');
543
+ const staging = join(tmpRoot, `${name}.${process.pid}`);
544
+ const previous = `${staging}.prev`;
545
+ if (pathExists(previous))
546
+ throw general(`cannot replace plugin "${name}": prior interrupted replacement remains at ${previous}`);
547
+ ensureDir(tmpRoot);
548
+ ensureDir(dirname(root));
549
+ removePath(staging);
550
+ let staged;
551
+ try {
552
+ ensureDir(staging);
553
+ writeStagedBundle(staging, manifest, validated);
554
+ staged = await options.validateStaged(staging, manifest);
555
+ let movedPrevious = false;
556
+ if (pathExists(root)) {
557
+ renameSync(root, previous);
558
+ movedPrevious = true;
559
+ }
560
+ try {
561
+ renameSync(staging, root);
562
+ }
563
+ catch (error) {
564
+ if (movedPrevious)
565
+ renameSync(previous, root);
566
+ throw error;
567
+ }
568
+ if (movedPrevious)
569
+ removePath(previous);
570
+ }
571
+ finally {
572
+ removePath(staging);
573
+ }
574
+ // Directory swap, then the config's version, then the state's validator — in
575
+ // that order, and never the reverse. A process killed mid-sequence leaves the
576
+ // ledger naming an OLDER generation than the disk, which the next check
577
+ // repairs: the server 200s against a stale-or-absent validator, the hash says
578
+ // the tree on disk is already that generation, and only the ledger is
579
+ // rewritten. The forbidden state — a validator for a generation the disk does
580
+ // not have, and so a permanent 304 against a package that was never unpacked
581
+ // — is unreachable from this order.
582
+ ensureScopeInitialized(scope, scopeRootPath);
583
+ updateConfig(scope, (cfg) => {
584
+ cfg.plugins[name] = { enabled: options.enable ? true : (cfg.plugins[name]?.enabled ?? true), version };
585
+ });
586
+ recordCheckedGeneration(scope, name, options.etag, { swapped: true });
587
+ return { manifest, validated, version, root, staged };
588
+ }
@@ -0,0 +1,24 @@
1
+ export interface HttpPluginFetchTarget {
2
+ name: string;
3
+ endpoint: string;
4
+ authEnv?: string;
5
+ }
6
+ export interface HttpPluginRegistration extends HttpPluginFetchTarget {
7
+ root: string;
8
+ commands: string;
9
+ scope: 'user' | 'project';
10
+ }
11
+ export interface EndpointValidation {
12
+ isValid: boolean;
13
+ errors: string[];
14
+ }
15
+ export declare function validateEndpoint(endpoint: unknown): EndpointValidation;
16
+ export declare function normalizeEndpoint(endpoint: string): string;
17
+ export declare function validateHttpPluginTransport(raw: unknown): {
18
+ transport?: {
19
+ kind: 'http';
20
+ endpoint: string;
21
+ authEnv?: string;
22
+ };
23
+ errors: string[];
24
+ };