@rho-workspace/rho 0.5.4 → 0.5.5

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 (102) hide show
  1. package/dist/browser-access.js +3 -2
  2. package/dist/capabilities.d.ts +2 -2
  3. package/dist/capabilities.js +6 -1
  4. package/dist/cli.js +186 -173
  5. package/dist/contract.generated.d.ts +329 -4
  6. package/dist/contract.generated.js +1 -1
  7. package/dist/contracts/owners.generated.js +360 -6
  8. package/dist/discovery.d.ts +1 -0
  9. package/dist/discovery.js +3 -3
  10. package/dist/extension-contract.d.ts +99 -0
  11. package/dist/extension-contract.js +52 -0
  12. package/dist/extension-directory.d.ts +9 -0
  13. package/dist/extension-directory.js +11 -0
  14. package/dist/extension-worker.d.ts +1 -0
  15. package/dist/extension-worker.js +36 -0
  16. package/dist/extensions.d.ts +157 -0
  17. package/dist/extensions.js +349 -0
  18. package/dist/plugin-cli.d.ts +1 -0
  19. package/dist/plugin-cli.js +139 -0
  20. package/dist/plugin-inventory.json +18 -0
  21. package/dist/plugins.d.ts +3 -3
  22. package/dist/project-work.d.ts +3 -1
  23. package/dist/project-work.js +15 -2
  24. package/dist/protocol.d.ts +7 -0
  25. package/dist/protocol.js +4 -2
  26. package/dist/server.d.ts +4 -1
  27. package/dist/server.js +55 -8
  28. package/dist/web/.vite/manifest.json +61 -61
  29. package/dist/web/assets/{canvas-BXhC8RDZ.js → canvas-D8rDdfDz.js} +1 -1
  30. package/dist/web/assets/choices-b0XhrGQV.js +1 -0
  31. package/dist/web/assets/{console-Dbdo-5oO.js → console-BSjLGe0T.js} +1 -1
  32. package/dist/web/assets/{dist-B2V0VDO0.js → dist-Buk0lwzI.js} +2 -2
  33. package/dist/web/assets/dist-Cw8hRIlT.js +1 -0
  34. package/dist/web/assets/dist-Tzoe_zm-.js +3 -0
  35. package/dist/web/assets/{dist-D25Vn17i.js → dist-foH9IKc6.js} +1 -1
  36. package/dist/web/assets/explore-in-BVUy3ZW_.js +1 -0
  37. package/dist/web/assets/graph-1OdIJ9l7.js +2 -0
  38. package/dist/web/assets/index-CsC3sTRq.css +1 -0
  39. package/dist/web/assets/{index-BHQIaDsx.js → index-CxmItXqa.js} +85 -85
  40. package/dist/web/assets/{notes-meaning-DdPbtNzJ.js → notes-meaning-pVbbQUGP.js} +1 -1
  41. package/dist/web/assets/overview-B9UtVJ32.js +6 -0
  42. package/dist/web/assets/preview-CksD_5KS.js +1 -0
  43. package/dist/web/assets/{review-CmWou4Iv.js → review-CMxQlQ-C.js} +1 -1
  44. package/dist/web/assets/{skills-Dy7n3L9d.js → skills-BFNSyrnD.js} +1 -1
  45. package/dist/web/assets/{skills-work-Cj0W6b-I.js → skills-work-BGPSnF_I.js} +1 -1
  46. package/dist/web/assets/{studio-process-QOY_n2_8.js → studio-process-CsRoTHOh.js} +2 -2
  47. package/dist/web/assets/{timeline-events-DmX1BCh3.js → timeline-events-BbuuFWHV.js} +1 -1
  48. package/dist/web/assets/{work-turns-Co0C71rJ.js → work-turns-BvJlIMmA.js} +1 -1
  49. package/dist/web/assets/workspace-CWlkjLNx.js +5 -0
  50. package/dist/web/composition.json +67 -67
  51. package/dist/web/index.html +3 -3
  52. package/dist/web/renderer.json +1 -1
  53. package/dist/web-assets.d.ts +2 -1
  54. package/dist/web-assets.js +3 -2
  55. package/dist/work-contract.js +2 -2
  56. package/docs/HEADLESS.md +1 -1
  57. package/docs/PLUGIN-AUTHORING.md +4 -0
  58. package/docs/THIRD-PARTY-PLUGINS.md +190 -0
  59. package/node_modules/@rho-workspace/plugin-agent/README.md +39 -0
  60. package/node_modules/@rho-workspace/plugin-agent/dist/activity-normalize.d.ts +1 -0
  61. package/node_modules/@rho-workspace/plugin-agent/dist/activity-normalize.js +2 -2
  62. package/node_modules/@rho-workspace/plugin-agent/dist/dsh-session.d.ts +6 -0
  63. package/node_modules/@rho-workspace/plugin-agent/dist/dsh-session.js +174 -0
  64. package/node_modules/@rho-workspace/plugin-agent/dist/external-session-contract.d.ts +82 -0
  65. package/node_modules/@rho-workspace/plugin-agent/dist/external-session-contract.js +15 -0
  66. package/node_modules/@rho-workspace/plugin-agent/dist/external-session.d.ts +73 -0
  67. package/node_modules/@rho-workspace/plugin-agent/dist/external-session.js +190 -0
  68. package/node_modules/@rho-workspace/plugin-agent/dist/index.d.ts +373 -0
  69. package/node_modules/@rho-workspace/plugin-agent/dist/index.js +1 -0
  70. package/node_modules/@rho-workspace/plugin-agent/dist/operations.d.ts +33 -0
  71. package/node_modules/@rho-workspace/plugin-agent/dist/operations.js +3 -0
  72. package/node_modules/@rho-workspace/plugin-agent/dist/presentation.d.ts +14 -0
  73. package/node_modules/@rho-workspace/plugin-agent/dist/presentation.js +2 -0
  74. package/node_modules/@rho-workspace/plugin-agent/dist/ui-external-session.d.ts +15 -0
  75. package/node_modules/@rho-workspace/plugin-agent/dist/ui-external-session.js +37 -0
  76. package/node_modules/@rho-workspace/plugin-agent/package.json +5 -1
  77. package/package.json +5 -3
  78. package/protocol/index.json +58 -4
  79. package/protocol/operations/agent.sessions.list.json +100 -0
  80. package/protocol/operations/agent.sessions.read.json +266 -0
  81. package/protocol/operations/extension.call.json +132 -0
  82. package/protocol/operations/extension.content.json +78 -0
  83. package/protocol/operations/extension.describe.json +195 -0
  84. package/protocol/operations/extension.install.json +198 -0
  85. package/protocol/operations/extension.list.json +123 -0
  86. package/protocol/operations/extension.manage.json +55 -0
  87. package/protocol/operations/extension.read.json +112 -0
  88. package/protocol/operations/project.work.read.json +3 -2
  89. package/protocol/operations/session.grant.json +9 -0
  90. package/scripts/third-party-acceptance.mjs +77 -0
  91. package/vendor/composition.json +85 -85
  92. package/vendor/rho-workspace-plugin-agent-0.4.29.tgz +0 -0
  93. package/dist/web/assets/choices-DU9kHlyQ.js +0 -1
  94. package/dist/web/assets/dist-BV3hWfn_.js +0 -3
  95. package/dist/web/assets/dist-CLNF2g-E.js +0 -1
  96. package/dist/web/assets/explore-in-8Q5ftt-E.js +0 -1
  97. package/dist/web/assets/graph-hS2kvHTI.js +0 -2
  98. package/dist/web/assets/index-Cj1_iqyK.css +0 -1
  99. package/dist/web/assets/overview-BV6ZO5TO.js +0 -6
  100. package/dist/web/assets/preview-n5lOOdjN.js +0 -1
  101. package/dist/web/assets/workspace-BKFefRfJ.js +0 -5
  102. package/vendor/rho-workspace-plugin-agent-0.4.28.tgz +0 -0
@@ -0,0 +1,157 @@
1
+ export declare const extensionLimits: {
2
+ maxPlugins: number;
3
+ maxVersions: number;
4
+ maxFiles: number;
5
+ maxPackageBytes: number;
6
+ maxContentBytes: number;
7
+ maxReceipts: number;
8
+ maxResultBytes: number;
9
+ maxRunning: number;
10
+ maxDelegations: number;
11
+ timeoutMs: number;
12
+ installTimeoutMs: number;
13
+ };
14
+ export interface ExtensionManifest {
15
+ apiVersion: 1;
16
+ id: string;
17
+ title: string;
18
+ description: string;
19
+ host?: string;
20
+ operations: {
21
+ id: string;
22
+ title: string;
23
+ description: string;
24
+ effect: 'read' | 'native';
25
+ methods: string[];
26
+ inputSchema: Record<string, any>;
27
+ }[];
28
+ skills: {
29
+ id: string;
30
+ title: string;
31
+ file: string;
32
+ }[];
33
+ views: {
34
+ id: string;
35
+ title: string;
36
+ file: string;
37
+ }[];
38
+ }
39
+ export interface InstalledExtension {
40
+ id: string;
41
+ name: string;
42
+ version: string;
43
+ title: string;
44
+ description: string;
45
+ digest: string;
46
+ source: string;
47
+ sourceCommit?: string;
48
+ enabled: boolean;
49
+ installedAt: string;
50
+ manifest: ExtensionManifest;
51
+ }
52
+ interface Receipt {
53
+ invocationId: string;
54
+ caller: string;
55
+ projectId: string;
56
+ pluginId: string;
57
+ packageDigest: string;
58
+ operation: string;
59
+ requestDigest: string;
60
+ state: 'pending' | 'completed' | 'unknown';
61
+ startedAt: string;
62
+ endedAt?: string;
63
+ value?: unknown;
64
+ error?: string;
65
+ delegations?: {
66
+ method: string;
67
+ request: unknown;
68
+ state: 'pending' | 'completed' | 'unknown';
69
+ value?: unknown;
70
+ error?: string;
71
+ }[];
72
+ }
73
+ export declare class Extensions {
74
+ readonly directory: string;
75
+ readonly limits: typeof extensionLimits;
76
+ private installed;
77
+ private running;
78
+ private workers;
79
+ private receiptCount;
80
+ private mutation;
81
+ constructor(directory: string, limits?: Partial<typeof extensionLimits>);
82
+ init(): Promise<void>;
83
+ close(): void;
84
+ private serial;
85
+ list(): {
86
+ directory: {
87
+ id: string;
88
+ title: string;
89
+ version: string;
90
+ description: string;
91
+ source: string;
92
+ repository: string;
93
+ needs: string;
94
+ }[];
95
+ plugins: {
96
+ id: string;
97
+ name: string;
98
+ version: string;
99
+ title: string;
100
+ description: string;
101
+ digest: string;
102
+ source: string;
103
+ sourceCommit?: string;
104
+ enabled: boolean;
105
+ installedAt: string;
106
+ }[];
107
+ limits: {
108
+ maxPlugins: number;
109
+ maxVersions: number;
110
+ maxFiles: number;
111
+ maxPackageBytes: number;
112
+ maxContentBytes: number;
113
+ maxReceipts: number;
114
+ maxResultBytes: number;
115
+ maxRunning: number;
116
+ maxDelegations: number;
117
+ timeoutMs: number;
118
+ installTimeoutMs: number;
119
+ };
120
+ };
121
+ describe(id: string): InstalledExtension;
122
+ private selected;
123
+ private root;
124
+ private file;
125
+ private hash;
126
+ check(root: string): Promise<{
127
+ name: string;
128
+ version: string;
129
+ manifest: ExtensionManifest;
130
+ }>;
131
+ private verify;
132
+ content(id: string, expected: string, kind: 'skill' | 'view', entryId: string): Promise<{
133
+ pluginId: string;
134
+ packageDigest: string;
135
+ entryId: string;
136
+ kind: "skill" | "view";
137
+ title: string;
138
+ text: string;
139
+ }>;
140
+ install(source: string, replace?: string, expected?: string): Promise<InstalledExtension>;
141
+ manage(id: string, expected: string, action: 'enable' | 'disable' | 'remove'): Promise<{
142
+ pluginId: string;
143
+ action: "remove" | "enable" | "disable";
144
+ retained: string;
145
+ }>;
146
+ read(invocationId: string, caller: string, projectId: string): Promise<Receipt>;
147
+ call(b: {
148
+ pluginId: string;
149
+ expectedDigest: string;
150
+ operation: string;
151
+ input: any;
152
+ changeId: string;
153
+ projectId: string;
154
+ }, caller: string, projectRoot: string, dispatch: (method: string, body: any, effect: 'read' | 'native') => Promise<unknown>): Promise<Receipt>;
155
+ private execute;
156
+ }
157
+ export {};
@@ -0,0 +1,349 @@
1
+ import { extensionDirectory } from './extension-directory.js';
2
+ import * as fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { fork, spawn } from 'node:child_process';
6
+ import { Ajv } from 'ajv';
7
+ import { atomicJson, canonical, digest, requireThat } from '@rho-workspace/plugin-contracts';
8
+ export const extensionLimits = { maxPlugins: 32, maxVersions: 128, maxFiles: 20000, maxPackageBytes: 128 * 1024 * 1024, maxContentBytes: 512 * 1024, maxReceipts: 2000, maxResultBytes: 1024 * 1024, maxRunning: 4, maxDelegations: 32, timeoutMs: 30000, installTimeoutMs: 180000 };
9
+ const ajv = new Ajv({ strict: false, allErrors: true });
10
+ const identifier = /^[a-z0-9][a-z0-9._-]{0,95}$/;
11
+ const relativeFile = (v) => typeof v === 'string' && v.length < 512 && !path.isAbsolute(v) && !v.includes('\\') && v.split('/').every(p => p !== '..' && p !== '.' && p !== '');
12
+ const boundedText = (v, maximum = 2048) => typeof v === 'string' && v.length > 0 && v.length <= maximum;
13
+ async function command(file, args, cwd, timeout) {
14
+ return new Promise((resolve, reject) => {
15
+ const child = spawn(file, args, { cwd, env: { ...process.env, npm_config_ignore_scripts: 'true', GIT_TERMINAL_PROMPT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] });
16
+ let output = '', killed = false;
17
+ const timer = setTimeout(() => { killed = true; child.kill(); }, timeout);
18
+ for (const stream of [child.stdout, child.stderr])
19
+ stream?.on('data', chunk => { output = (output + chunk).slice(-8192); });
20
+ child.on('error', error => { clearTimeout(timer); reject(error); });
21
+ child.on('exit', code => { clearTimeout(timer); code === 0 && !killed ? resolve(output) : reject(new Error(killed ? `Package staging exceeded installTimeoutMs (${timeout} ms). The selected package was kept.` : `${file} failed (${code}): ${output}`)); });
22
+ });
23
+ }
24
+ export class Extensions {
25
+ directory;
26
+ limits;
27
+ installed = [];
28
+ running = new Map();
29
+ workers = new Set();
30
+ receiptCount = 0;
31
+ mutation = Promise.resolve();
32
+ constructor(directory, limits = {}) {
33
+ this.directory = directory;
34
+ this.limits = { ...extensionLimits, ...limits };
35
+ for (const [key, value] of Object.entries(this.limits))
36
+ requireThat(Number.isSafeInteger(value) && value > 0, 'invalid', `Invalid extensions.${key}.`);
37
+ }
38
+ async init() {
39
+ await fs.mkdir(path.join(this.directory, 'versions'), { recursive: true });
40
+ await fs.mkdir(path.join(this.directory, 'receipts'), { recursive: true });
41
+ try {
42
+ this.installed = JSON.parse(await fs.readFile(path.join(this.directory, 'installed.json'), 'utf8'));
43
+ }
44
+ catch (e) {
45
+ if (e.code !== 'ENOENT')
46
+ throw e;
47
+ }
48
+ const receipts = await fs.readdir(path.join(this.directory, 'receipts'));
49
+ this.receiptCount = receipts.length;
50
+ for (const file of receipts) {
51
+ const receipt = JSON.parse(await fs.readFile(path.join(this.directory, 'receipts', file), 'utf8'));
52
+ if (receipt.state === 'pending')
53
+ await atomicJson(path.join(this.directory, 'receipts', file), { ...receipt, state: 'unknown', endedAt: new Date().toISOString(), error: 'The service ended before the plugin result was retained. Observe the original native operation; do not repeat it under a new identity.' });
54
+ }
55
+ }
56
+ close() { for (const child of this.workers)
57
+ child.kill(); }
58
+ serial(fn) { const next = this.mutation.then(fn, fn); this.mutation = next.catch(() => { }); return next; }
59
+ list() { return { directory: extensionDirectory, plugins: this.installed.map(({ manifest, ...info }) => info), limits: this.limits }; }
60
+ describe(id) { const value = this.installed.find(p => p.id === id); requireThat(value, 'not_found', 'Plugin is not installed.'); return structuredClone(value); }
61
+ selected(id, expectedDigest) { const value = this.describe(id); requireThat(value.digest === expectedDigest, 'stale', 'The selected plugin changed. Read its descriptor again.'); return value; }
62
+ root(p) { return path.join(this.directory, 'versions', p.digest, 'node_modules', p.name); }
63
+ async file(root, relative) {
64
+ requireThat(relativeFile(relative), 'invalid', 'Plugin entry must be a relative file within its package.');
65
+ const resolved = await fs.realpath(path.join(root, relative));
66
+ requireThat(resolved.startsWith(await fs.realpath(root) + path.sep) && (await fs.stat(resolved)).isFile(), 'scope', 'Plugin entry escapes its package or is not a file.');
67
+ return resolved;
68
+ }
69
+ async hash(root) {
70
+ root = await fs.realpath(root);
71
+ const hash = createHash('sha256');
72
+ let bytes = 0, files = 0;
73
+ const walk = async (dir) => {
74
+ for (const item of (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
75
+ requireThat(++files <= this.limits.maxFiles, 'limit', 'Plugin tree exceeds extensions.maxFiles; the selected package is kept.');
76
+ const target = path.join(dir, item.name), relative = path.relative(root, target);
77
+ if (item.isDirectory()) {
78
+ hash.update('dir:' + relative + '\0');
79
+ await walk(target);
80
+ }
81
+ else if (item.isSymbolicLink()) {
82
+ const resolved = await fs.realpath(target);
83
+ requireThat(resolved.startsWith(root + path.sep) && (await fs.stat(resolved)).isFile(), 'scope', 'Package links must resolve to files within the installed tree.');
84
+ hash.update('link:' + relative + '\0' + await fs.readlink(target) + '\0');
85
+ }
86
+ else {
87
+ requireThat(item.isFile(), 'invalid', 'Only regular files and contained file links are supported.');
88
+ bytes += (await fs.stat(target)).size;
89
+ requireThat(bytes <= this.limits.maxPackageBytes, 'limit', 'Plugin tree exceeds extensions.maxPackageBytes; the selected package is kept.');
90
+ hash.update('file:' + relative + '\0');
91
+ hash.update(await fs.readFile(target));
92
+ hash.update('\0');
93
+ }
94
+ }
95
+ };
96
+ await walk(root);
97
+ return hash.digest('hex');
98
+ }
99
+ async check(root) {
100
+ const pkg = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8'));
101
+ requireThat(boundedText(pkg.name, 214) && /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(pkg.name) && boundedText(pkg.version, 64), 'invalid', 'Plugin needs an npm package name and version.');
102
+ requireThat(relativeFile(pkg.rhoPlugin), 'invalid', 'package.json must name its manifest in rhoPlugin.');
103
+ const manifestPath = await this.file(root, pkg.rhoPlugin);
104
+ requireThat((await fs.stat(manifestPath)).size <= 65536, 'limit', 'Plugin manifest exceeds 64 KiB.');
105
+ const m = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
106
+ requireThat(m.apiVersion === 1 && typeof m.id === 'string' && identifier.test(m.id) && boundedText(m.title, 120) && boundedText(m.description), 'invalid', 'Plugin manifest needs apiVersion 1, id, title and description.');
107
+ for (const [kind, maximum] of [['operations', 32], ['skills', 16], ['views', 16]]) {
108
+ requireThat(Array.isArray(m[kind]) && m[kind].length <= maximum && new Set(m[kind].map(x => x.id)).size === m[kind].length, 'invalid', `Invalid or duplicate ${kind}.`);
109
+ for (const entry of m[kind])
110
+ requireThat(typeof entry.id === 'string' && identifier.test(entry.id) && boundedText(entry.title, 120), 'invalid', `Invalid ${kind} entry.`);
111
+ }
112
+ if (m.operations.length) {
113
+ requireThat(m.host, 'invalid', 'Operations need a host entry.');
114
+ await this.file(root, m.host);
115
+ }
116
+ for (const op of m.operations) {
117
+ requireThat(boundedText(op.description) && ['read', 'native'].includes(op.effect) && Array.isArray(op.methods) && op.methods.length <= 16 && op.methods.every(x => typeof x === 'string' && /^[a-z][a-zA-Z.]+$/.test(x) && !x.startsWith('extension.') && !x.startsWith('session.') && !x.startsWith('project.')), 'invalid', 'Invalid operation effect or delegated Rho methods.');
118
+ requireThat(op.inputSchema?.type === 'object', 'invalid', 'An operation inputSchema must describe an object.');
119
+ ajv.compile(op.inputSchema);
120
+ }
121
+ for (const entry of [...m.skills, ...m.views]) {
122
+ const file = await this.file(root, entry.file);
123
+ requireThat((await fs.stat(file)).size <= this.limits.maxContentBytes, 'limit', 'Plugin entry exceeds extensions.maxContentBytes.');
124
+ }
125
+ return { name: pkg.name, version: pkg.version, manifest: m };
126
+ }
127
+ async verify(p) { requireThat(await this.hash(path.join(this.directory, 'versions', p.digest, 'node_modules')) === p.digest, 'conflict', 'Installed plugin bytes changed. Reinstall the package before use.'); }
128
+ async content(id, expected, kind, entryId) {
129
+ const p = this.selected(id, expected);
130
+ if (kind === 'view')
131
+ requireThat(p.enabled, 'unavailable', 'This plugin is disabled.');
132
+ await this.verify(p);
133
+ const entry = p.manifest[kind === 'skill' ? 'skills' : 'views'].find(x => x.id === entryId);
134
+ requireThat(entry, 'not_found', 'Plugin entry not found.');
135
+ return { pluginId: id, packageDigest: p.digest, entryId, kind, title: entry.title, text: await fs.readFile(await this.file(this.root(p), entry.file), 'utf8') };
136
+ }
137
+ install(source, replace, expected) {
138
+ return this.serial(async () => {
139
+ requireThat(!!replace === !!expected, 'invalid', 'An update needs both replace and expectedDigest.');
140
+ if (replace)
141
+ this.selected(replace, expected ?? '');
142
+ requireThat(replace || this.installed.length < this.limits.maxPlugins, 'limit', 'Installed plugins reached extensions.maxPlugins. Remove one before adding another.');
143
+ const versions = await fs.readdir(path.join(this.directory, 'versions'));
144
+ requireThat(versions.length < this.limits.maxVersions, 'limit', 'Retained package revisions reached extensions.maxVersions. Existing packages and receipts are kept; increase the configured limit before installing.');
145
+ const stage = await fs.mkdtemp(path.join(this.directory, 'staging-'));
146
+ try {
147
+ let spec = source, sourceCommit;
148
+ if (source.startsWith('git+https://')) {
149
+ const [remote, selector = 'HEAD'] = source.slice(4).split('#');
150
+ const [ref, subdir] = selector.split(':');
151
+ const url = new URL(remote);
152
+ requireThat(!url.username && !url.password && !url.search && /^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(ref) && (!subdir || relativeFile(subdir)), 'invalid', 'Use a public HTTPS Git source with #ref:relative/package. Credentials and query strings are not accepted.');
153
+ const sourceDirectory = path.join(stage, 'source');
154
+ await fs.mkdir(sourceDirectory);
155
+ const repo = await fs.realpath(sourceDirectory);
156
+ for (const args of [['init', '-q'], ['remote', 'add', 'origin', remote], ['fetch', '-q', '--depth=1', 'origin', ref], ['checkout', '-q', '--detach', 'FETCH_HEAD']])
157
+ await command('git', args, repo, this.limits.installTimeoutMs);
158
+ sourceCommit = (await command('git', ['rev-parse', 'HEAD'], repo, this.limits.installTimeoutMs)).trim();
159
+ spec = path.join(repo, subdir ?? '');
160
+ requireThat((await fs.realpath(spec)).startsWith(repo + path.sep) || spec === repo, 'scope', 'Git package escapes its source.');
161
+ }
162
+ else if (source.startsWith('https://')) {
163
+ const url = new URL(source);
164
+ requireThat(!url.username && !url.password && !url.search && url.pathname.endsWith('.tgz'), 'invalid', 'Use a public HTTPS .tgz without credentials or query strings.');
165
+ }
166
+ else {
167
+ const local = source.startsWith('file:') ? source.slice(5) : source;
168
+ const present = await fs.stat(path.resolve(local)).catch(() => null);
169
+ if (present) {
170
+ spec = path.resolve(local);
171
+ source = spec;
172
+ }
173
+ else
174
+ requireThat(/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+(?:@[a-zA-Z0-9.*^~+_-]+)?$/.test(source), 'invalid', 'Use an npm package, existing local folder/tarball, HTTPS .tgz, or git+https source.');
175
+ }
176
+ const install = path.join(stage, 'install');
177
+ await fs.mkdir(install);
178
+ await atomicJson(path.join(install, 'package.json'), { name: 'rho-plugin-installation', version: '1.0.0', private: true });
179
+ const npmCli = /npm-cli\.js$/.test(process.env.npm_execpath ?? '') ? process.env.npm_execpath : process.platform === 'win32' ? path.join(path.dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js') : undefined;
180
+ await command(npmCli ? process.execPath : 'npm', [...(npmCli ? [npmCli] : []), 'install', '--ignore-scripts', '--engine-strict', '--omit=dev', '--no-audit', '--no-fund', '--install-links', '--', spec], install, this.limits.installTimeoutMs);
181
+ const pkg = JSON.parse(await fs.readFile(path.join(install, 'package.json'), 'utf8')), names = Object.keys(pkg.dependencies ?? {});
182
+ requireThat(names.length === 1, 'invalid', 'Install exactly one plugin package.');
183
+ const checked = await this.check(path.join(install, 'node_modules', names[0]));
184
+ requireThat(!replace || checked.manifest.id === replace, 'conflict', 'An update must keep the plugin identity.');
185
+ requireThat(replace || !this.installed.some(p => p.id === checked.manifest.id), 'conflict', 'Plugin is already installed. Use update with its current digest.');
186
+ await fs.rm(path.join(install, 'node_modules', '.package-lock.json'), { force: true });
187
+ const sha = await this.hash(path.join(install, 'node_modules'));
188
+ const selected = { id: checked.manifest.id, name: checked.name, version: checked.version, title: checked.manifest.title, description: checked.manifest.description, digest: sha, source, ...(sourceCommit ? { sourceCommit } : {}), enabled: replace ? this.describe(replace).enabled : true, installedAt: new Date().toISOString(), manifest: checked.manifest };
189
+ const destination = path.join(this.directory, 'versions', sha);
190
+ if (!versions.includes(sha))
191
+ await fs.rename(install, destination);
192
+ const next = [...this.installed.filter(p => p.id !== selected.id), selected];
193
+ await atomicJson(path.join(this.directory, 'installed.json'), next);
194
+ this.installed = next;
195
+ return selected;
196
+ }
197
+ finally {
198
+ await fs.rm(stage, { recursive: true, force: true });
199
+ }
200
+ });
201
+ }
202
+ manage(id, expected, action) {
203
+ return this.serial(async () => {
204
+ const p = this.selected(id, expected);
205
+ const next = this.installed.filter(x => x.id !== id);
206
+ if (action !== 'remove')
207
+ next.push({ ...p, enabled: action === 'enable' });
208
+ await atomicJson(path.join(this.directory, 'installed.json'), next);
209
+ this.installed = next;
210
+ return { pluginId: id, action, retained: 'Native sessions, project data, receipts and package revisions are retained.' };
211
+ });
212
+ }
213
+ async read(invocationId, caller, projectId) {
214
+ let receipt;
215
+ try {
216
+ receipt = JSON.parse(await fs.readFile(path.join(this.directory, 'receipts', invocationId + '.json'), 'utf8'));
217
+ }
218
+ catch (e) {
219
+ if (e.code !== 'ENOENT')
220
+ throw e;
221
+ requireThat(false, 'not_found', 'Plugin invocation not found.');
222
+ }
223
+ requireThat(receipt.caller === caller && receipt.projectId === projectId, 'scope', 'This plugin invocation belongs to another caller or project.');
224
+ return receipt;
225
+ }
226
+ async call(b, caller, projectRoot, dispatch) {
227
+ const invocationId = digest(canonical([caller, b.projectId, b.pluginId, b.changeId]));
228
+ const requestDigest = digest(canonical(b));
229
+ // Read the receipt before the selected package: an update or removal must not
230
+ // turn a retry of an acknowledged write into another invocation.
231
+ const earlier = await this.read(invocationId, caller, b.projectId).catch((e) => { if (e.kind !== 'not_found')
232
+ throw e; return null; });
233
+ if (earlier) {
234
+ requireThat(earlier.requestDigest === requestDigest, 'conflict', 'changeId already names a different plugin request.');
235
+ return earlier;
236
+ }
237
+ const active = this.running.get(invocationId);
238
+ if (active) {
239
+ const receipt = await active;
240
+ requireThat(receipt.requestDigest === requestDigest, 'conflict', 'changeId already names a different plugin request.');
241
+ return receipt;
242
+ }
243
+ const p = this.selected(b.pluginId, b.expectedDigest);
244
+ requireThat(p.enabled, 'unavailable', 'This plugin is disabled.');
245
+ const operation = p.manifest.operations.find(op => op.id === b.operation);
246
+ requireThat(operation, 'not_found', 'Plugin operation not found.');
247
+ const validate = ajv.compile(operation.inputSchema);
248
+ requireThat(validate(b.input), 'invalid', 'Input does not match the plugin operation schema.', { problems: validate.errors });
249
+ requireThat(this.running.size < this.limits.maxRunning, 'limit', 'Plugin calls reached extensions.maxRunning. Existing calls continue.');
250
+ const retained = operation.effect !== 'read';
251
+ if (retained) {
252
+ requireThat(this.receiptCount < this.limits.maxReceipts, 'limit', 'Plugin receipts reached extensions.maxReceipts. Existing receipts are kept; increase the limit before another write.');
253
+ this.receiptCount++;
254
+ }
255
+ const run = async () => {
256
+ await this.verify(p);
257
+ const receipt = { invocationId, caller, projectId: b.projectId, pluginId: p.id, packageDigest: p.digest, operation: b.operation, requestDigest, state: 'pending', startedAt: new Date().toISOString() };
258
+ const filename = path.join(this.directory, 'receipts', invocationId + '.json');
259
+ let writes = Promise.resolve();
260
+ const save = () => { const snapshot = structuredClone(receipt); const next = writes.then(() => atomicJson(filename, snapshot)); writes = next.catch(() => { }); return next; };
261
+ if (retained)
262
+ await save();
263
+ const delegate = async (method, body) => {
264
+ const entry = { method, request: body, state: 'pending' };
265
+ const entries = [...(receipt.delegations ?? []), entry];
266
+ requireThat(Buffer.byteLength(JSON.stringify({ ...receipt, delegations: entries })) <= this.limits.maxResultBytes, 'limit', 'Plugin receipt reached extensions.maxResultBytes before delegation. No further native request was submitted.');
267
+ receipt.delegations = entries;
268
+ if (retained)
269
+ await save();
270
+ try {
271
+ const value = await dispatch(method, body, operation.effect);
272
+ if (Buffer.byteLength(JSON.stringify({ ...receipt, candidate: value })) > this.limits.maxResultBytes)
273
+ throw new Error('Native reply exceeds extensions.maxResultBytes. Its original request is retained; observe output with the native owner.');
274
+ entry.value = value;
275
+ entry.state = 'completed';
276
+ if (retained)
277
+ await save();
278
+ return value;
279
+ }
280
+ catch (error) {
281
+ entry.state = 'unknown';
282
+ entry.error = error instanceof Error ? error.message : String(error);
283
+ if (retained)
284
+ await save();
285
+ throw error;
286
+ }
287
+ };
288
+ try {
289
+ const value = await this.execute(p, operation, b.input, invocationId, projectRoot, delegate);
290
+ requireThat(Buffer.byteLength(JSON.stringify({ ...receipt, value })) <= this.limits.maxResultBytes, 'limit', 'Plugin result exceeds extensions.maxResultBytes. Delegated native identities and available replies are retained.');
291
+ receipt.value = value;
292
+ receipt.state = 'completed';
293
+ }
294
+ catch (error) {
295
+ receipt.state = 'unknown';
296
+ receipt.error = error instanceof Error ? error.message : String(error);
297
+ }
298
+ receipt.endedAt = new Date().toISOString();
299
+ if (retained)
300
+ await save();
301
+ return receipt;
302
+ };
303
+ const promise = run();
304
+ this.running.set(invocationId, promise);
305
+ try {
306
+ return await promise;
307
+ }
308
+ finally {
309
+ this.running.delete(invocationId);
310
+ }
311
+ }
312
+ async execute(p, operation, input, invocationId, projectRoot, dispatch) {
313
+ const entry = await this.file(this.root(p), p.manifest.host);
314
+ return new Promise((resolve, reject) => {
315
+ const child = fork(new URL('./extension-worker.js', import.meta.url), [], { cwd: projectRoot, env: { PATH: process.env.PATH, HOME: process.env.HOME, SystemRoot: process.env.SystemRoot }, stdio: ['ignore', 'ignore', 'ignore', 'ipc'], execArgv: [] });
316
+ this.workers.add(child);
317
+ let ended = false, calls = 0;
318
+ const finish = (error, value) => { if (ended)
319
+ return; ended = true; clearTimeout(timer); this.workers.delete(child); child.kill(); error ? reject(error) : resolve(value); };
320
+ const timer = setTimeout(() => finish(new Error('Plugin exceeded extensions.timeoutMs. Its native operations may still be running; observe their original identities.')), this.limits.timeoutMs);
321
+ child.on('error', error => finish(error));
322
+ child.on('exit', () => finish(new Error('Plugin exited before its result was received. The native outcome is unknown.')));
323
+ child.on('message', async (message) => {
324
+ if (ended)
325
+ return;
326
+ if (Buffer.byteLength(JSON.stringify(message)) > this.limits.maxResultBytes)
327
+ return finish(new Error('Plugin exceeded extensions.maxResultBytes. Native output remains with its owner; the plugin result is unknown.'));
328
+ if (message.type === 'result')
329
+ return finish(undefined, message.value);
330
+ if (message.type === 'error')
331
+ return finish(new Error(String(message.error)));
332
+ if (message.type === 'call') {
333
+ try {
334
+ requireThat(++calls <= this.limits.maxDelegations, 'limit', 'Plugin reached extensions.maxDelegations; no further Rho call was submitted.');
335
+ requireThat(operation.methods.includes(message.method), 'scope', 'Plugin did not declare this Rho method.');
336
+ const value = await dispatch(message.method, message.body);
337
+ if (!ended && child.connected)
338
+ child.send({ type: 'reply', id: message.id, value });
339
+ }
340
+ catch (error) {
341
+ if (!ended && child.connected)
342
+ child.send({ type: 'reply', id: message.id, error: error instanceof Error ? error.message : String(error) });
343
+ }
344
+ }
345
+ });
346
+ child.send({ type: 'start', entry, operation: operation.id, input, projectRoot, invocationId });
347
+ });
348
+ }
349
+ }
@@ -0,0 +1 @@
1
+ export declare function pluginCli(args: string[]): Promise<void>;
@@ -0,0 +1,139 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import { watch } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { parseArgs } from 'node:util';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { Extensions } from './extensions.js';
8
+ import { RhoClient } from './client.js';
9
+ const help = `Independent Rho plugins (API 1)
10
+ rho plugin create DIRECTORY [--id my-plugin]
11
+ rho plugin check DIRECTORY
12
+ rho plugin add SOURCE [--connection FILE]
13
+ rho plugin list
14
+ rho plugin update ID [--source SOURCE]
15
+ rho plugin enable|disable|remove ID
16
+ rho plugin dev DIRECTORY
17
+ rho plugin call ID OPERATION --project RELATIVE_FOLDER --input JSON [--change-id ID]
18
+
19
+ SOURCE: npm package, local folder/.tgz, public HTTPS .tgz, or
20
+ git+https://host/repo.git#ref:relative/package
21
+ Management uses the running Rho's private connection file (default ~/Rho/state/connection.json).
22
+ Only install packages you trust: hosts run native code with your OS permissions.
23
+ Install skips lifecycle scripts. Build packages before adding them.
24
+ dev watches source and installs checked snapshots; an invalid edit keeps the previous version.
25
+ Remove preserves project files, live sessions, receipts and retained package revisions.
26
+ `;
27
+ export async function pluginCli(args) {
28
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { connection: { type: 'string' }, id: { type: 'string' }, source: { type: 'string' }, project: { type: 'string' }, input: { type: 'string' }, 'change-id': { type: 'string' }, help: { type: 'boolean' } } });
29
+ const [command, target, operation] = positionals;
30
+ if (values.help || !command) {
31
+ process.stdout.write(help);
32
+ return;
33
+ }
34
+ if (command === 'create') {
35
+ if (!target)
36
+ throw new Error('Name a new plugin directory.');
37
+ const id = values.id ?? path.basename(path.resolve(target));
38
+ if (!/^[a-z0-9][a-z0-9._-]{0,95}$/.test(id))
39
+ throw new Error('Use a lowercase plugin id containing letters, digits, dots, underscores or hyphens.');
40
+ await fs.mkdir(path.resolve(target));
41
+ const manifest = { apiVersion: 1, id, title: id, description: 'An independent Rho plugin.', host: 'host.mjs', operations: [{ id: 'greet', title: 'Greet', description: 'Return a greeting without changing project data.', effect: 'read', methods: [], inputSchema: { type: 'object', properties: { name: { type: 'string', maxLength: 120 } }, required: ['name'], additionalProperties: false } }], skills: [{ id: 'guide', title: 'Use this plugin', file: 'SKILL.md' }], views: [{ id: 'main', title: id, file: 'view.html' }] };
42
+ const files = {
43
+ 'package.json': JSON.stringify({ name: 'rho-plugin-' + id, version: '0.1.0', type: 'module', license: 'MIT', rhoPlugin: 'rho-plugin.json', files: ['rho-plugin.json', 'host.mjs', 'view.html', 'SKILL.md'] }, null, 2) + '\n',
44
+ 'rho-plugin.json': JSON.stringify(manifest, null, 2) + '\n',
45
+ 'host.mjs': `export async function run(operation, input, context) {\n if (operation === 'greet') return { greeting: 'Hello, ' + input.name + '!' };\n throw new Error('Unknown operation');\n}\n`,
46
+ 'SKILL.md': `# ${id}\n\nRead extension.describe for the installed digest and operation schemas. Call greet with a name. This operation reads only. Use a stable changeId for native operations; observe extension.read after a lost reply.\n`,
47
+ 'view.html': `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${id}</title><style>body{font:16px system-ui;margin:24px;color:#24312b}input,button{font:inherit;padding:8px}output{display:block;margin-top:16px}</style><h1>${id}</h1><form><label>Your name <input name="name" value="World" maxlength="120"></label> <button>Greet</button></form><output aria-live="polite"></output><script>document.querySelector('form').onsubmit=async e=>{e.preventDefault();const out=document.querySelector('output');try{const result=await rho.call('greet',{name:e.target.elements.name.value});out.textContent=result.value?.greeting||result.error}catch(error){out.textContent=error.message}};</script></html>\n`,
48
+ 'README.md': `# ${id}\n\nDevelop: \`rho plugin dev .\`\nCheck: \`rho plugin check .\`\nDistribute: \`npm pack --ignore-scripts\`, then share the tarball, Git package source or publish to your own npm namespace.\n\nSee Rho's docs/THIRD-PARTY-PLUGINS.md for the host and iframe APIs, permissions and contribution process.\n`,
49
+ };
50
+ for (const [file, text] of Object.entries(files))
51
+ await fs.writeFile(path.join(target, file), text, { flag: 'wx' });
52
+ process.stdout.write(`Created ${path.resolve(target)}. Run rho plugin check, then rho plugin dev.\n`);
53
+ return;
54
+ }
55
+ if (command === 'check') {
56
+ if (!target)
57
+ throw new Error('Name a plugin directory.');
58
+ const checked = await new Extensions('').check(path.resolve(target));
59
+ process.stdout.write(JSON.stringify({ valid: true, ...checked }, null, 2) + '\n');
60
+ return;
61
+ }
62
+ const client = await RhoClient.fromFile(path.resolve(values.connection ?? path.join(homedir(), 'Rho', 'state', 'connection.json')));
63
+ const list = async () => (await client.call('extension.list', {})).plugins;
64
+ const find = async () => { const p = (await list()).find(p => p.id === target); if (!p)
65
+ throw new Error('Plugin is not installed: ' + target); return p; };
66
+ // npm/Git staging can take longer than the ordinary 30-second observation client.
67
+ // There is no execution retry here; a lost response is reconciled with list.
68
+ const install = async (body) => {
69
+ const local = body.source.startsWith('file:') ? body.source.slice(5) : body.source;
70
+ if (await fs.stat(path.resolve(local)).catch(() => null))
71
+ body = { ...body, source: path.resolve(local) };
72
+ const response = await fetch(new URL('/api/v2/extension.install', client.connection.url), { method: 'POST', headers: { authorization: 'Bearer ' + client.connection.token, 'content-type': 'application/json' }, body: JSON.stringify(body) });
73
+ const result = await response.json();
74
+ if (!result.ok)
75
+ throw new Error(result.error.message);
76
+ return result.value;
77
+ };
78
+ let result;
79
+ if (command === 'list')
80
+ result = await list();
81
+ else if (command === 'add') {
82
+ if (!target)
83
+ throw new Error('Name a package source.');
84
+ result = await install({ source: target });
85
+ }
86
+ else if (command === 'update') {
87
+ const p = await find();
88
+ result = await install({ source: values.source ?? p.source, replace: p.id, expectedDigest: p.digest });
89
+ }
90
+ else if (['enable', 'disable', 'remove'].includes(command)) {
91
+ const p = await find();
92
+ result = await client.call('extension.manage', { pluginId: p.id, expectedDigest: p.digest, action: command });
93
+ }
94
+ else if (command === 'call') {
95
+ if (!operation || !values.project)
96
+ throw new Error('Call needs an operation and --project.');
97
+ const p = await find(), project = await client.call('project.open', { path: values.project });
98
+ const changeId = values['change-id'] ?? randomUUID();
99
+ process.stderr.write('changeId: ' + changeId + '\n');
100
+ result = await client.call('extension.call', { projectId: project.id, pluginId: p.id, expectedDigest: p.digest, operation, input: JSON.parse(values.input ?? '{}'), changeId });
101
+ }
102
+ else if (command === 'dev') {
103
+ if (!target)
104
+ throw new Error('Name a plugin directory.');
105
+ const root = path.resolve(target), checked = await new Extensions('').check(root);
106
+ let busy = false, pending = false;
107
+ const update = async () => {
108
+ if (busy) {
109
+ pending = true;
110
+ return;
111
+ }
112
+ busy = true;
113
+ try {
114
+ const p = (await list()).find(p => p.id === checked.manifest.id);
115
+ const installed = await install({ source: root, ...(p ? { replace: p.id, expectedDigest: p.digest } : {}) });
116
+ process.stdout.write(`Installed ${installed.id} ${installed.version} ${installed.digest}. Reopen its view to load this snapshot.\n`);
117
+ }
118
+ catch (e) {
119
+ process.stderr.write((e instanceof Error ? e.message : String(e)) + '\n');
120
+ }
121
+ finally {
122
+ busy = false;
123
+ if (pending) {
124
+ pending = false;
125
+ void update();
126
+ }
127
+ }
128
+ };
129
+ await update();
130
+ let timer;
131
+ watch(root, { recursive: true }, (_event, filename) => { if (filename?.split(/[\\/]/).some(p => p === 'node_modules' || p === '.git'))
132
+ return; clearTimeout(timer); timer = setTimeout(() => void update(), 400); });
133
+ process.stdout.write('Watching ' + root + '. Stop with Ctrl-C.\n');
134
+ return;
135
+ }
136
+ else
137
+ throw new Error('Unknown plugin command.\n' + help);
138
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
139
+ }