@uniqbit/mate-core 0.18.1-canary.0 → 0.18.1-canary.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-core",
3
- "version": "0.18.1-canary.0",
3
+ "version": "0.18.1-canary.1",
4
4
  "description": "Core framework and plugin APIs for Mate.",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/uniqbit-ag/mate-cli.git",
9
+ "directory": "packages/mate-core"
10
+ },
6
11
  "files": [
7
12
  "src/",
8
13
  "wrappers/",
@@ -2,6 +2,8 @@ import fs from "node:fs";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import path from "node:path";
4
4
 
5
+ import { runPreferringSsh, type GitResult } from "../../../../runtime/companion-git";
6
+
5
7
  export interface RebaseResult {
6
8
  ok: boolean;
7
9
  conflictedPaths: string[];
@@ -46,7 +48,7 @@ export interface GitOps {
46
48
 
47
49
  function gitRun(
48
50
  companionPath: string,
49
- args: string[],
51
+ args: readonly string[],
50
52
  ): { status: number; out: string; err: string } {
51
53
  const {
52
54
  GIT_DIR: _gitDir,
@@ -112,7 +114,13 @@ export function defaultGitOps(
112
114
  workingRepoPath = process.env.MATE_REPO_PATH,
113
115
  ): GitOps {
114
116
  assertSafeCompanionRoot(companionPath, workingRepoPath);
115
- const exec = (args: string[]) => gitRun(companionPath, args);
117
+ const exec = (args: readonly string[]) => gitRun(companionPath, args);
118
+ const toResult = (res: ReturnType<typeof exec>): GitResult => ({
119
+ status: res.status,
120
+ stdout: res.out,
121
+ stderr: res.err,
122
+ });
123
+ const execNetwork = (args: string[]) => runPreferringSsh(exec, args, toResult);
116
124
  const execOrThrow = (args: string[]) => {
117
125
  const res = exec(args);
118
126
  if (res.status !== 0) {
@@ -185,7 +193,8 @@ export function defaultGitOps(
185
193
  return exec(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).status === 0;
186
194
  },
187
195
  async fetch() {
188
- execOrThrow(["fetch"]);
196
+ const res = execNetwork(["fetch"]);
197
+ if (res.status !== 0) throw new Error(`git fetch failed: ${res.err || res.out}`);
189
198
  },
190
199
  async rebaseOntoUpstream() {
191
200
  const res = exec(["rebase", "--autostash", "@{u}"]);
@@ -205,7 +214,7 @@ export function defaultGitOps(
205
214
  execOrThrow(["tag", "-a", name, "-m", message]);
206
215
  },
207
216
  async push() {
208
- const res = exec(["push", "--follow-tags"]);
217
+ const res = execNetwork(["push", "--follow-tags"]);
209
218
  return { ok: res.status === 0, error: res.err || res.out };
210
219
  },
211
220
  };
@@ -19,6 +19,7 @@ import { runSetupFlowAtPath } from "../setup";
19
19
  import { runInstallCommand } from "../install";
20
20
  import type { CompanionSource, LinkedRepository } from "../../../lib/orchestrator/types";
21
21
  import { invalidateInstallState } from "../../../lib/install";
22
+ import { toSshUrl } from "../../../runtime/companion-git";
22
23
 
23
24
  interface CompanionLinkCommandDeps {
24
25
  selectCompanionLinkInputs?: (
@@ -61,23 +62,6 @@ export async function runCompanionLinkCommand(argv: string[]): Promise<void> {
61
62
  return companionLinkCommandDeps.runCompanionLinkCommandWithDeps(argv);
62
63
  }
63
64
 
64
- function toSshUrl(url: string): string | null {
65
- try {
66
- const parsed = new URL(url.trim());
67
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
68
- if (!parsed.hostname || parsed.pathname === "/") return null;
69
-
70
- const repositoryPath = parsed.pathname.replace(/^\/+/, "");
71
- if (!repositoryPath) return null;
72
-
73
- // Keep the usual scp-style form when no SSH port is specified.
74
- if (!parsed.port) return `git@${parsed.hostname}:${repositoryPath}`;
75
- return `ssh://git@${parsed.hostname}:${parsed.port}/${repositoryPath}`;
76
- } catch {
77
- return null;
78
- }
79
- }
80
-
81
65
  function exitWithError(message: string): never {
82
66
  process.stderr.write(`${message}\n`);
83
67
  process.exit(1);
@@ -19,6 +19,7 @@ import {
19
19
  FrozenInstallError,
20
20
  installDeclaredPluginsFrozen,
21
21
  } from "../../tools/setup/dynamic-plugins/frozen";
22
+ import { verifyTrackedPluginOutputs } from "../../tools/setup/dynamic-plugins/staging";
22
23
  import { verifyDeclaredPlugins } from "../../tools/setup/dynamic-plugins/verify";
23
24
 
24
25
  export function reportPluginInstallResults(results: PluginInstallResult[]): boolean {
@@ -158,7 +159,22 @@ export async function runInstallCommand(argv: string[], cwd = process.cwd()): Pr
158
159
  }
159
160
 
160
161
  try {
161
- await reconcileInstalledCompanion(plan);
162
+ if (frozen && plan.context.companionPath) {
163
+ const { syncCompanionFiles } = await import("../../tools/setup");
164
+ const drift = await verifyTrackedPluginOutputs(plan.context.companionPath, (staged) =>
165
+ syncCompanionFiles(staged, plan.context.config),
166
+ );
167
+ if (drift.length > 0) {
168
+ process.stderr.write(
169
+ `${FRAMEWORK_NAME}: plugin-generated files differ from the committed checkout: ${drift.join(", ")}\n` +
170
+ `Prepare and commit them in the companion's authoring flow; the checkout was left unchanged.\n`,
171
+ );
172
+ process.exitCode = 1;
173
+ return false;
174
+ }
175
+ } else {
176
+ await reconcileInstalledCompanion(plan);
177
+ }
162
178
  await saveCompleteInstallState(plan, execution.results);
163
179
  } catch (error) {
164
180
  process.stderr.write(
@@ -11,7 +11,7 @@ export async function runPluginCommand(
11
11
  await runPluginInstallCommand(argv);
12
12
  return;
13
13
  case "verify":
14
- await runPluginVerifyCommand(argv);
14
+ await runPluginVerifyCommand();
15
15
  return;
16
16
  default:
17
17
  console.error(`Unknown plugin command: ${subcommand ?? ""}`);
@@ -1,22 +1,19 @@
1
1
  import { FRAMEWORK_NAME } from "../../../framework";
2
2
  import { resolveInstallContext } from "../../../lib/install";
3
- import { inspectDeclaredPlugins } from "../../../tools/setup/dynamic-plugins/verify";
3
+ import { verifyDeclaredPlugins } from "../../../tools/setup/dynamic-plugins/verify";
4
4
 
5
5
  /**
6
6
  * @command mate plugin verify
7
- * @description Strict, installation-free check that every plugin declared by the companion is installed, and loadable with the current environment. Exits non-zero naming each failing package. With `--json`, prints `{"capabilities": [...]}` listing the capability IDs the verified plugins provide.
7
+ * @description Strict, installation-free check that every plugin declared by the companion is allowed by `MATE_ALLOWED_PLUGINS`, installed, and loadable with the current environment. Exits non-zero naming each failing package.
8
8
  */
9
- export async function runPluginVerifyCommand(
10
- argv: string[] = [],
11
- cwd = process.cwd(),
12
- ): Promise<boolean> {
9
+ export async function runPluginVerifyCommand(cwd = process.cwd()): Promise<boolean> {
13
10
  const context = await resolveInstallContext(cwd);
14
11
  if ((context.kind !== "companion" && context.kind !== "hub") || !context.companionPath) {
15
12
  process.stderr.write(`${FRAMEWORK_NAME}: \`plugin verify\` requires a companion context.\n`);
16
13
  process.exitCode = 1;
17
14
  return false;
18
15
  }
19
- const { failures, capabilities } = await inspectDeclaredPlugins(context.companionPath);
16
+ const failures = await verifyDeclaredPlugins(context.companionPath);
20
17
  for (const failure of failures) {
21
18
  process.stderr.write(`${FRAMEWORK_NAME}: plugin ${failure.package}: ${failure.reason}\n`);
22
19
  }
@@ -24,6 +21,5 @@ export async function runPluginVerifyCommand(
24
21
  process.exitCode = 1;
25
22
  return false;
26
23
  }
27
- if (argv.includes("--json")) process.stdout.write(`${JSON.stringify({ capabilities })}\n`);
28
24
  return true;
29
25
  }
@@ -1,4 +1,3 @@
1
- import fs from "node:fs";
2
1
  import path from "node:path";
3
2
 
4
3
  import { FRAMEWORK_NAME } from "../../../framework";
@@ -27,56 +26,11 @@ export async function launchableAgents(
27
26
  return TERMINAL_AGENTS.filter((agent) => allowed.includes(agent) && installed(agent));
28
27
  }
29
28
 
30
- /** Lowercase letters, digits and hyphens, so a name can never carry shell syntax or a flag. */
31
- export const AGENT_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
32
-
33
- const AGENT_DEFINITION_DIRS: Record<TerminalAgent, string> = {
34
- claude: path.join(".claude", "agents"),
35
- opencode: path.join(".opencode", "agents"),
36
- };
37
-
38
- export interface DefaultAgentOutcome {
39
- agentArgs?: string[];
40
- notice?: string;
41
- }
42
-
43
- /**
44
- * The companion's `studio.terminal.agent` for one provider. Never throws and
45
- * never echoes the configured value into the notice.
46
- */
47
- export async function defaultAgentFor(
48
- companionPath: string,
49
- agent: TerminalAgent,
50
- ): Promise<DefaultAgentOutcome> {
51
- const configPath = path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml");
52
- if (!fs.existsSync(configPath)) return {};
53
- let name: unknown;
54
- try {
55
- name = (await new ConfigStore(configPath).load()).studio?.terminal?.agent;
56
- } catch {
57
- return {};
58
- }
59
- if (name === undefined || name === null) return {};
60
- if (typeof name !== "string" || !AGENT_NAME_PATTERN.test(name)) {
61
- return {
62
- notice: "Default agent not applied: studio.terminal.agent is not a valid agent name.",
63
- };
64
- }
65
- const definition = path.join(companionPath, AGENT_DEFINITION_DIRS[agent], `${name}.md`);
66
- if (!fs.existsSync(definition)) {
67
- return {
68
- notice: `Default agent "${name}" not applied: this companion has no ${agent} definition for it.`,
69
- };
70
- }
71
- return { agentArgs: ["--agent", name] };
72
- }
73
-
74
29
  export interface LaunchResolverOptions {
75
30
  collectInventory: () => Promise<StudioInventory>;
76
31
  /** Pinned by `serve --companion`; overrides the page selection. */
77
32
  launchCompanion?: string | null;
78
33
  launchableAgents?: (companionPath: string) => Promise<TerminalAgent[]>;
79
- defaultAgent?: (companionPath: string, agent: TerminalAgent) => Promise<DefaultAgentOutcome>;
80
34
  }
81
35
 
82
36
  /** The companion a launch targets: the pinned one, else the page's, both read from the current inventory. */
@@ -96,7 +50,6 @@ export function createLaunchResolver(
96
50
  options: LaunchResolverOptions,
97
51
  ): (agent: string, digest: string | null) => Promise<TerminalLaunchResolution> {
98
52
  const agents = options.launchableAgents ?? launchableAgents;
99
- const defaultAgent = options.defaultAgent ?? defaultAgentFor;
100
53
  return async (agent, digest) => {
101
54
  const companion = await effectiveLaunchCompanion(
102
55
  await options.collectInventory(),
@@ -113,9 +66,6 @@ export function createLaunchResolver(
113
66
  if (!(await agents(companion.path)).includes(agent as TerminalAgent)) {
114
67
  return { reason: `${agent} is not allowed by this companion or is not installed` };
115
68
  }
116
- return {
117
- companionPath: companion.path,
118
- ...(await defaultAgent(companion.path, agent as TerminalAgent)),
119
- };
69
+ return { companionPath: companion.path };
120
70
  };
121
71
  }
@@ -177,15 +177,7 @@ export interface TerminalSessionInfo {
177
177
  startedAt: number;
178
178
  }
179
179
 
180
- export type TerminalLaunchResolution =
181
- | {
182
- companionPath: string;
183
- /** Extra argv entries for the managed launch; set only by companion configuration. */
184
- agentArgs?: string[];
185
- /** Written to the terminal before the agent starts. */
186
- notice?: string;
187
- }
188
- | { reason: string };
180
+ export type TerminalLaunchResolution = { companionPath: string } | { reason: string };
189
181
 
190
182
  export interface TerminalRegistryOptions {
191
183
  detachMs: number;
@@ -396,7 +388,7 @@ export class TerminalRegistry {
396
388
  void this.finish(detached, { type: "ended", reason: "evicted" });
397
389
  }
398
390
 
399
- const session = this.launch(agent, resolved.companionPath, size, resolved);
391
+ const session = this.launch(agent, resolved.companionPath, size);
400
392
  this.attachTo(connection, session, size);
401
393
  }
402
394
 
@@ -404,7 +396,6 @@ export class TerminalRegistry {
404
396
  agent: TerminalAgent,
405
397
  companionPath: string,
406
398
  size: { cols: number; rows: number },
407
- extras: { agentArgs?: string[]; notice?: string } = {},
408
399
  ): Session {
409
400
  const env: Record<string, string> = {};
410
401
  for (const [key, value] of Object.entries(this.options.env ?? process.env)) {
@@ -422,7 +413,6 @@ export class TerminalRegistry {
422
413
  "--companion",
423
414
  "--yes",
424
415
  ...(this.options.noGit ? ["--no-git"] : []),
425
- ...(extras.agentArgs ?? []),
426
416
  ];
427
417
 
428
418
  const session = {
@@ -437,7 +427,6 @@ export class TerminalRegistry {
437
427
  ending: null,
438
428
  exited: false,
439
429
  } as Omit<Session, "process"> as Session;
440
- if (extras.notice) session.ring.push(new TextEncoder().encode(`${extras.notice}\r\n`));
441
430
  session.process = this.spawn({
442
431
  argv,
443
432
  cwd: companionPath,
@@ -10,7 +10,10 @@ import {
10
10
  gitEnvironment,
11
11
  isAuthenticationFailure,
12
12
  outputLines,
13
+ REMOTE_URLS_QUERY,
13
14
  resolveUpstreamTargetWith,
15
+ shouldRetryWithoutSsh,
16
+ sshRewriteArgs,
14
17
  type GitResult,
15
18
  } from "../../runtime/companion-git";
16
19
  import { recordCompanionSync } from "../../runtime/companion-git-state";
@@ -172,11 +175,7 @@ export class CompanionGitSync {
172
175
  }
173
176
  const dirty = outputLines(before.stdout).length > 0;
174
177
 
175
- const fetch = await this.command(
176
- companionPath,
177
- ["fetch", target.remote, target.branch],
178
- interactiveGit ? "interactive" : undefined,
179
- );
178
+ const fetch = await this.fetch(companionPath, target, interactiveGit);
180
179
  if (fetch.status !== 0) {
181
180
  const authenticationFailure = isAuthenticationFailure(fetch);
182
181
  const recovery = interactiveGit
@@ -248,6 +247,25 @@ export class CompanionGitSync {
248
247
  };
249
248
  }
250
249
 
250
+ /**
251
+ * SSH attempt stays captured: a failure there is expected noise, and SSH
252
+ * still prompts for passphrases or host keys on the terminal itself.
253
+ */
254
+ private async fetch(
255
+ companionPath: string,
256
+ target: SyncTarget,
257
+ interactiveGit: boolean,
258
+ ): Promise<GitCommandResult> {
259
+ const args = ["fetch", target.remote, target.branch];
260
+ const remotes = await this.command(companionPath, REMOTE_URLS_QUERY);
261
+ const rewrite = remotes.status === 0 ? sshRewriteArgs(remotes.stdout) : [];
262
+ if (rewrite.length > 0) {
263
+ const overSsh = await this.command(companionPath, [...rewrite, ...args]);
264
+ if (!shouldRetryWithoutSsh(overSsh)) return overSsh;
265
+ }
266
+ return this.command(companionPath, args, interactiveGit ? "interactive" : undefined);
267
+ }
268
+
251
269
  private async mergeAndRestore(
252
270
  companionPath: string,
253
271
  target: SyncTarget,
@@ -17,6 +17,7 @@ import {
17
17
  import { hydrateDynamicPlugins } from "../../tools/setup/dynamic-plugins/hydrate";
18
18
  import { applySetupCompatibilities } from "../../tools/setup";
19
19
  import { findRepoLocalRegistryFile } from "./repo-local-registry";
20
+ import { runPreferringSsh, toSshUrl } from "../../runtime/companion-git";
20
21
 
21
22
  export interface GitCommandResult {
22
23
  status: number | null;
@@ -217,10 +218,15 @@ export async function materializeHubMember(
217
218
  await fs.mkdir(path.dirname(destination), { recursive: true });
218
219
  try {
219
220
  if (source.kind === "git") {
220
- const args = ["clone"];
221
- if (source.ref) args.push("--branch", source.ref);
222
- args.push(source.url!, destination);
223
- gitOutputOrThrow(git(hubPath, args), `Cloning ${source.url}`);
221
+ const branch = source.ref ? ["--branch", source.ref] : [];
222
+ const cloneFrom = (url: string) => git(hubPath, ["clone", ...branch, url, destination]);
223
+ const sshUrl = toSshUrl(source.url!);
224
+ let clone = sshUrl ? cloneFrom(sshUrl) : undefined;
225
+ if (!clone || clone.status !== 0) {
226
+ if (clone) await fs.rm(destination, { recursive: true, force: true });
227
+ clone = cloneFrom(source.url!);
228
+ }
229
+ gitOutputOrThrow(clone, `Cloning ${source.url}`);
224
230
  } else {
225
231
  await copyWithoutGit(source.path!, destination);
226
232
  }
@@ -288,7 +294,11 @@ async function syncHubMember(
288
294
  return { id: member.id, status: "dirty", message: "local changes must be resolved first" };
289
295
  }
290
296
 
291
- const fetch = git(memberPath, ["fetch", "origin"]);
297
+ const fetch = runPreferringSsh(
298
+ (args) => git(memberPath, [...args]),
299
+ ["fetch", "origin"],
300
+ (result) => ({ ...result, status: result.status ?? 1 }),
301
+ );
292
302
  if (fetch.status !== 0) {
293
303
  return { id: member.id, status: "failed", message: fetch.stderr || "fetch failed" };
294
304
  }
@@ -120,11 +120,6 @@ export interface PluginDeclaration {
120
120
  config?: unknown;
121
121
  }
122
122
 
123
- export interface StudioTerminalConfig {
124
- /** Persona selected with `--agent` when Studio's terminal starts a provider. */
125
- agent?: string;
126
- }
127
-
128
123
  export interface FrameworkConfig {
129
124
  type?: FrameworkType;
130
125
  git?: GitModeProfile;
@@ -136,7 +131,6 @@ export interface FrameworkConfig {
136
131
  cliTools?: CliToolConfig[];
137
132
  packageManagers?: string[];
138
133
  engines?: EngineConstraints;
139
- studio?: { terminal?: StudioTerminalConfig };
140
134
  }
141
135
 
142
136
  export interface CompanionRegistryConfig {
@@ -61,6 +61,72 @@ export function isAuthenticationFailure(result: GitResult): boolean {
61
61
  ].some((marker) => output.includes(marker));
62
62
  }
63
63
 
64
+ /** `git@host:path` (`ssh://` when a port is given) for an HTTP(S) Git URL; `null` otherwise. */
65
+ export function toSshUrl(url: string): string | null {
66
+ try {
67
+ const parsed = new URL(url.trim());
68
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
69
+ if (!parsed.hostname || parsed.pathname === "/") return null;
70
+
71
+ const repositoryPath = parsed.pathname.replace(/^\/+/, "");
72
+ if (!repositoryPath) return null;
73
+
74
+ if (!parsed.port) return `git@${parsed.hostname}:${repositoryPath}`;
75
+ return `ssh://git@${parsed.hostname}:${parsed.port}/${repositoryPath}`;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /** Lists every configured remote URL, one `remote.<name>.<key> <url>` per line. */
82
+ export const REMOTE_URLS_QUERY: readonly string[] = [
83
+ "config",
84
+ "--get-regexp",
85
+ String.raw`^remote\..+\.(url|pushurl)$`,
86
+ ];
87
+
88
+ /**
89
+ * `-c url.<ssh>.insteadOf=<https>` per HTTP(S) remote in `REMOTE_URLS_QUERY`
90
+ * output, so one invocation reaches every remote over SSH without rewriting
91
+ * the stored configuration. Empty when no remote is HTTP(S).
92
+ */
93
+ export function sshRewriteArgs(remoteUrlsOutput: string): string[] {
94
+ const urls = new Set(
95
+ outputLines(remoteUrlsOutput).flatMap((line) => {
96
+ const url = line.slice(line.indexOf(" ") + 1).trim();
97
+ return url ? [url] : [];
98
+ }),
99
+ );
100
+ return [...urls].flatMap((url) => {
101
+ const ssh = toSshUrl(url);
102
+ return ssh ? ["-c", `url.${ssh}.insteadOf=${url}`] : [];
103
+ });
104
+ }
105
+
106
+ /** A remote rejection is a verdict the configured URL would repeat; anything else may be SSH-only. */
107
+ export function shouldRetryWithoutSsh(result: GitResult): boolean {
108
+ if (result.status === 0) return false;
109
+ return !/\[(?:remote )?rejected\]/.test(`${result.stderr}\n${result.stdout}`);
110
+ }
111
+
112
+ /**
113
+ * Runs a network command over SSH first when a remote is HTTP(S), then as
114
+ * configured. `run` is the caller's own Git driver.
115
+ */
116
+ export function runPreferringSsh<R extends { status: number | null }>(
117
+ run: (args: readonly string[]) => R,
118
+ args: readonly string[],
119
+ toResult: (result: R) => GitResult,
120
+ ): R {
121
+ const remotes = toResult(run(REMOTE_URLS_QUERY));
122
+ const rewrite = remotes.status === 0 ? sshRewriteArgs(remotes.stdout) : [];
123
+ if (rewrite.length > 0) {
124
+ const overSsh = run([...rewrite, ...args]);
125
+ if (!shouldRetryWithoutSsh(toResult(overSsh))) return overSsh;
126
+ }
127
+ return run(args);
128
+ }
129
+
64
130
  /**
65
131
  * Inherited `GIT_*` overrides would point Git at the session's own repository
66
132
  * rather than the companion, so they are stripped from every invocation.
@@ -30,6 +30,7 @@ import {
30
30
  outputLines,
31
31
  resolveUpstreamTargetSync,
32
32
  runGitSync,
33
+ runPreferringSsh,
33
34
  type CompanionForkState,
34
35
  } from "./companion-git";
35
36
  import { hasLaunchEnvironment } from "./env";
@@ -130,10 +131,10 @@ export function syncCompanionUnattended(
130
131
  );
131
132
  }
132
133
 
133
- const fetch = runGitSync(
134
- companionPath,
134
+ const fetch = runPreferringSsh(
135
+ (args) => runGitSync(companionPath, args, remaining()),
135
136
  ["fetch", "--no-progress", target.remote, target.branch],
136
- remaining(),
137
+ (result) => result,
137
138
  );
138
139
  if (fetch.status !== 0) {
139
140
  if (isAuthenticationFailure(fetch)) {
@@ -5,8 +5,14 @@ import path from "node:path";
5
5
  import type { PluginDeclaration } from "../../../lib/orchestrator/types";
6
6
  import type { NpmInstallRunner, PluginInstallResult } from "./install";
7
7
  import { dynamicPluginsWorkspaceRoot } from "./paths";
8
-
9
- /** Raised before anything is installed: manifest or lockfile inputs are unusable. */
8
+ import {
9
+ disallowedPluginMessage,
10
+ isPluginAllowed,
11
+ PluginPolicyError,
12
+ readPluginPolicy,
13
+ } from "./policy";
14
+
15
+ /** Raised before anything is installed: policy, manifest or lockfile inputs are unusable. */
10
16
  export class FrozenInstallError extends Error {}
11
17
 
12
18
  export interface FrozenInstallDeps {
@@ -106,7 +112,8 @@ async function treeMismatches(workspaceRoot: string, lock: Lockfile): Promise<st
106
112
 
107
113
  /**
108
114
  * Deployment restore: installs exactly what the committed lockfile records.
109
- * Inputs are validated before npm runs — the workspace manifest against the declarations, and the lockfile against the
115
+ * Inputs are validated before npm runs — the appliance allowlist, the
116
+ * workspace manifest against the declarations, and the lockfile against the
110
117
  * manifest — and neither tracked file is ever rewritten. An installed tree is
111
118
  * reused only when it matches the lockfile's versions and integrity.
112
119
  */
@@ -120,6 +127,20 @@ export async function installDeclaredPluginsFrozen(
120
127
  const desired: Record<string, string> = {};
121
128
  for (const declaration of sorted) desired[declaration.package] = declaration.version;
122
129
 
130
+ let policy: ReturnType<typeof readPluginPolicy>;
131
+ try {
132
+ policy = readPluginPolicy(deps.env ?? process.env);
133
+ } catch (error) {
134
+ if (error instanceof PluginPolicyError) throw new FrozenInstallError(error.message);
135
+ throw error;
136
+ }
137
+ const refused = sorted.filter((declaration) => !isPluginAllowed(policy, declaration.package));
138
+ if (refused.length > 0) {
139
+ throw new FrozenInstallError(
140
+ refused.map((declaration) => disallowedPluginMessage(declaration.package)).join("\n"),
141
+ );
142
+ }
143
+
123
144
  const manifestFile = path.join(workspaceRoot, "package.json");
124
145
  const lockFile = path.join(workspaceRoot, "package-lock.json");
125
146
  const [manifestText, lockText] = await Promise.all([readText(manifestFile), readText(lockFile)]);
@@ -14,6 +14,12 @@ import {
14
14
  type PluginHost,
15
15
  } from "./host";
16
16
  import { dynamicPluginsWorkspaceRoot, pluginPackageRoot } from "./paths";
17
+ import {
18
+ disallowedPluginMessage,
19
+ isPluginAllowed,
20
+ PluginPolicyError,
21
+ readPluginPolicy,
22
+ } from "./policy";
17
23
 
18
24
  interface PluginManifest {
19
25
  mate?: { pluginApiVersion?: unknown };
@@ -47,6 +53,16 @@ export async function loadDynamicPlugin(
47
53
  const name = declaration.package;
48
54
  const packageRoot = pluginPackageRoot(companionPath, name);
49
55
 
56
+ /** Fails closed: a malformed policy allows nothing. Checked before any plugin file is touched. */
57
+ try {
58
+ if (!isPluginAllowed(readPluginPolicy(deps.env ?? process.env), name)) {
59
+ return { ok: false, warning: `${disallowedPluginMessage(name)}; not loaded.` };
60
+ }
61
+ } catch (error) {
62
+ if (!(error instanceof PluginPolicyError)) throw error;
63
+ return { ok: false, warning: `plugin "${name}" not loaded: ${error.message}` };
64
+ }
65
+
50
66
  let manifest: PluginManifest;
51
67
  try {
52
68
  manifest = JSON.parse(
@@ -0,0 +1,55 @@
1
+ /** Environment variable carrying the appliance's declared-plugin package allowlist. */
2
+ export const ALLOWED_PLUGINS_ENV = "MATE_ALLOWED_PLUGINS";
3
+
4
+ /**
5
+ * Parsed allowlist: exact package names and scope-wide `@scope/*` patterns.
6
+ * An empty list permits no package; the policy's absence permits all.
7
+ */
8
+ export interface PluginPolicy {
9
+ exact: string[];
10
+ scopes: string[];
11
+ }
12
+
13
+ export class PluginPolicyError extends Error {}
14
+
15
+ const EXACT_PATTERN = /^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
16
+ const SCOPE_PATTERN = /^@[a-z0-9][a-z0-9._~-]*\/\*$/;
17
+
18
+ /**
19
+ * Parses a comma-separated allowlist. `undefined` means no policy; an empty
20
+ * or blank string is an explicit empty policy. Malformed entries throw so a
21
+ * typo can never widen or silently narrow the trust decision.
22
+ */
23
+ export function parseAllowedPlugins(raw: string | undefined): PluginPolicy | null {
24
+ if (raw === undefined) return null;
25
+ const policy: PluginPolicy = { exact: [], scopes: [] };
26
+ if (raw.trim() === "") return policy;
27
+ for (const part of raw.split(",")) {
28
+ const entry = part.trim();
29
+ if (SCOPE_PATTERN.test(entry)) policy.scopes.push(entry.slice(0, entry.indexOf("/") + 1));
30
+ else if (EXACT_PATTERN.test(entry)) policy.exact.push(entry);
31
+ else {
32
+ throw new PluginPolicyError(
33
+ `${ALLOWED_PLUGINS_ENV}: malformed entry ${JSON.stringify(entry)}; expected an exact package name or a scope pattern such as "@acme/*"`,
34
+ );
35
+ }
36
+ }
37
+ return policy;
38
+ }
39
+
40
+ /** Reads the effective policy from an environment; throws on a malformed value. */
41
+ export function readPluginPolicy(
42
+ env: Record<string, string | undefined> = process.env,
43
+ ): PluginPolicy | null {
44
+ return parseAllowedPlugins(env[ALLOWED_PLUGINS_ENV]);
45
+ }
46
+
47
+ export function isPluginAllowed(policy: PluginPolicy | null, packageName: string): boolean {
48
+ if (policy === null) return true;
49
+ if (policy.exact.includes(packageName)) return true;
50
+ return policy.scopes.some((scope) => packageName.startsWith(scope));
51
+ }
52
+
53
+ export function disallowedPluginMessage(packageName: string): string {
54
+ return `plugin "${packageName}" is not allowed by ${ALLOWED_PLUGINS_ENV}`;
55
+ }
@@ -0,0 +1,53 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ export class TrackedOutputError extends Error {}
7
+
8
+ export type StagedProjection = (stagingPath: string) => Promise<void>;
9
+
10
+ function git(cwd: string, args: string[]): string {
11
+ const result = spawnSync("git", args, { cwd, encoding: "utf8" });
12
+ if (result.error || result.status !== 0) {
13
+ throw new TrackedOutputError(
14
+ `git ${args[0]} failed in ${cwd}: ${result.error?.message ?? result.stderr?.trim() ?? result.status}`,
15
+ );
16
+ }
17
+ return result.stdout;
18
+ }
19
+
20
+ function changedPaths(cwd: string): string[] {
21
+ return git(cwd, ["status", "--porcelain", "--untracked-files=all"])
22
+ .split("\n")
23
+ .filter((line) => line.trim() !== "")
24
+ .map((line) => line.slice(3));
25
+ }
26
+
27
+ /**
28
+ * Checks that what plugins would generate is already committed. The live
29
+ * checkout must have no edits of its own; the projection then runs against a
30
+ * disposable clone of its commit, and any file that differs afterwards is
31
+ * drift. Nothing is written back to the live checkout. Returns the drifting
32
+ * paths (empty when the checkout is complete).
33
+ */
34
+ export async function verifyTrackedPluginOutputs(
35
+ companionPath: string,
36
+ project: StagedProjection,
37
+ ): Promise<string[]> {
38
+ const pending = changedPaths(companionPath);
39
+ if (pending.length > 0) {
40
+ throw new TrackedOutputError(
41
+ `the checkout has uncommitted changes, which frozen setup will not overwrite: ${pending.join(", ")}`,
42
+ );
43
+ }
44
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "mate-staging-"));
45
+ try {
46
+ const staged = path.join(root, "companion");
47
+ git(root, ["clone", "--quiet", "--no-hardlinks", companionPath, staged]);
48
+ await project(staged);
49
+ return changedPaths(staged);
50
+ } finally {
51
+ await fs.rm(root, { recursive: true, force: true });
52
+ }
53
+ }
@@ -5,37 +5,38 @@ import type { PluginDeclaration } from "../../../lib/orchestrator/types";
5
5
  import { readDeclarations, validateDeclaration } from "./declarations";
6
6
  import { loadDynamicPlugin, type DynamicPluginLoadDeps } from "./loader";
7
7
  import { pluginPackageRoot } from "./paths";
8
+ import {
9
+ disallowedPluginMessage,
10
+ isPluginAllowed,
11
+ PluginPolicyError,
12
+ readPluginPolicy,
13
+ } from "./policy";
8
14
 
9
15
  export interface PluginVerificationFailure {
10
16
  package: string;
11
17
  reason: string;
12
18
  }
13
19
 
14
- export interface PluginInspection {
15
- failures: PluginVerificationFailure[];
16
- /** IDs of the capabilities the loaded plugins provide. */
17
- capabilities: string[];
18
- }
19
-
20
20
  /**
21
- * Strict, installation-free check of every declared plugin: installed
21
+ * Strict, installation-free check of every declared plugin: allowed, installed
22
22
  * and loadable with the effective environment. Unlike hydration it fails
23
- * closed on the first-class problems ordinary commands only warn about.
23
+ * closed on the first-class problems ordinary commands only warn about. Only
24
+ * allowlisted packages are imported.
24
25
  */
25
26
  export async function verifyDeclaredPlugins(
26
27
  companionPath: string,
27
28
  deps: DynamicPluginLoadDeps = {},
28
29
  ): Promise<PluginVerificationFailure[]> {
29
- return (await inspectDeclaredPlugins(companionPath, deps)).failures;
30
- }
31
-
32
- export async function inspectDeclaredPlugins(
33
- companionPath: string,
34
- deps: DynamicPluginLoadDeps = {},
35
- ): Promise<PluginInspection> {
36
30
  const env = deps.env ?? process.env;
37
31
  const failures: PluginVerificationFailure[] = [];
38
- const capabilities: string[] = [];
32
+ let policy: ReturnType<typeof readPluginPolicy>;
33
+ try {
34
+ policy = readPluginPolicy(env);
35
+ } catch (error) {
36
+ if (!(error instanceof PluginPolicyError)) throw error;
37
+ return [{ package: "(allowlist)", reason: error.message }];
38
+ }
39
+
39
40
  const declarations: PluginDeclaration[] = [];
40
41
  for (const entry of await readDeclarations(companionPath)) {
41
42
  const { declaration, error } = validateDeclaration(entry);
@@ -45,6 +46,10 @@ export async function inspectDeclaredPlugins(
45
46
 
46
47
  for (const declaration of declarations) {
47
48
  const name = declaration.package;
49
+ if (!isPluginAllowed(policy, name)) {
50
+ failures.push({ package: name, reason: disallowedPluginMessage(name) });
51
+ continue;
52
+ }
48
53
  // oxlint-disable-next-line no-await-in-loop -- declared order is part of the contract
49
54
  const installed = await fs
50
55
  .access(pluginPackageRoot(companionPath, name))
@@ -60,7 +65,6 @@ export async function inspectDeclaredPlugins(
60
65
  // oxlint-disable-next-line no-await-in-loop -- declared order is part of the contract
61
66
  const result = await loadDynamicPlugin(companionPath, declaration, { ...deps, env });
62
67
  if (!result.ok) failures.push({ package: name, reason: result.warning });
63
- else if (result.plugin.kind === "capability") capabilities.push(result.plugin.id);
64
68
  }
65
- return { failures, capabilities };
69
+ return failures;
66
70
  }