@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 +6 -1
- package/src/cli/commands/artifact/finish/git.ts +13 -4
- package/src/cli/commands/companion/link.ts +1 -17
- package/src/cli/commands/install.ts +17 -1
- package/src/cli/commands/plugin/plugin.ts +1 -1
- package/src/cli/commands/plugin/verify.ts +4 -8
- package/src/cli/commands/studio/terminal-launch.ts +1 -51
- package/src/cli/commands/studio/terminal.ts +2 -13
- package/src/lib/orchestrator/companion-git-sync.ts +23 -5
- package/src/lib/orchestrator/companion-hub.ts +15 -5
- package/src/lib/orchestrator/types.ts +0 -6
- package/src/runtime/companion-git.ts +66 -0
- package/src/runtime/companion-sync.ts +4 -3
- package/src/tools/setup/dynamic-plugins/frozen.ts +24 -3
- package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
- package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
- package/src/tools/setup/dynamic-plugins/staging.ts +53 -0
- package/src/tools/setup/dynamic-plugins/verify.ts +22 -18
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniqbit/mate-core",
|
|
3
|
-
"version": "0.18.1-canary.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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 =
|
|
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 =
|
|
134
|
-
companionPath,
|
|
134
|
+
const fetch = runPreferringSsh(
|
|
135
|
+
(args) => runGitSync(companionPath, args, remaining()),
|
|
135
136
|
["fetch", "--no-progress", target.remote, target.branch],
|
|
136
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
69
|
+
return failures;
|
|
66
70
|
}
|