@uniqbit/mate-core 0.17.2-canary.0 → 0.17.2-canary.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/commands/plugin/verify.ts +1 -1
- package/src/cli/commands/studio/terminal-launch.ts +51 -1
- package/src/cli/commands/studio/terminal.ts +13 -2
- package/src/lib/orchestrator/types.ts +6 -0
- package/src/tools/setup/dynamic-plugins/frozen.ts +3 -24
- package/src/tools/setup/dynamic-plugins/loader.ts +0 -16
- package/src/tools/setup/dynamic-plugins/verify.ts +3 -22
- package/src/tools/setup/dynamic-plugins/policy.ts +0 -55
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@ import { inspectDeclaredPlugins } from "../../../tools/setup/dynamic-plugins/ver
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @command mate plugin verify
|
|
7
|
-
* @description Strict, installation-free check that every plugin declared by the companion is
|
|
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.
|
|
8
8
|
*/
|
|
9
9
|
export async function runPluginVerifyCommand(
|
|
10
10
|
argv: string[] = [],
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
|
|
3
4
|
import { FRAMEWORK_NAME } from "../../../framework";
|
|
@@ -26,11 +27,56 @@ export async function launchableAgents(
|
|
|
26
27
|
return TERMINAL_AGENTS.filter((agent) => allowed.includes(agent) && installed(agent));
|
|
27
28
|
}
|
|
28
29
|
|
|
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
|
+
|
|
29
74
|
export interface LaunchResolverOptions {
|
|
30
75
|
collectInventory: () => Promise<StudioInventory>;
|
|
31
76
|
/** Pinned by `serve --companion`; overrides the page selection. */
|
|
32
77
|
launchCompanion?: string | null;
|
|
33
78
|
launchableAgents?: (companionPath: string) => Promise<TerminalAgent[]>;
|
|
79
|
+
defaultAgent?: (companionPath: string, agent: TerminalAgent) => Promise<DefaultAgentOutcome>;
|
|
34
80
|
}
|
|
35
81
|
|
|
36
82
|
/** The companion a launch targets: the pinned one, else the page's, both read from the current inventory. */
|
|
@@ -50,6 +96,7 @@ export function createLaunchResolver(
|
|
|
50
96
|
options: LaunchResolverOptions,
|
|
51
97
|
): (agent: string, digest: string | null) => Promise<TerminalLaunchResolution> {
|
|
52
98
|
const agents = options.launchableAgents ?? launchableAgents;
|
|
99
|
+
const defaultAgent = options.defaultAgent ?? defaultAgentFor;
|
|
53
100
|
return async (agent, digest) => {
|
|
54
101
|
const companion = await effectiveLaunchCompanion(
|
|
55
102
|
await options.collectInventory(),
|
|
@@ -66,6 +113,9 @@ export function createLaunchResolver(
|
|
|
66
113
|
if (!(await agents(companion.path)).includes(agent as TerminalAgent)) {
|
|
67
114
|
return { reason: `${agent} is not allowed by this companion or is not installed` };
|
|
68
115
|
}
|
|
69
|
-
return {
|
|
116
|
+
return {
|
|
117
|
+
companionPath: companion.path,
|
|
118
|
+
...(await defaultAgent(companion.path, agent as TerminalAgent)),
|
|
119
|
+
};
|
|
70
120
|
};
|
|
71
121
|
}
|
|
@@ -177,7 +177,15 @@ export interface TerminalSessionInfo {
|
|
|
177
177
|
startedAt: number;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
export type TerminalLaunchResolution =
|
|
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 };
|
|
181
189
|
|
|
182
190
|
export interface TerminalRegistryOptions {
|
|
183
191
|
detachMs: number;
|
|
@@ -388,7 +396,7 @@ export class TerminalRegistry {
|
|
|
388
396
|
void this.finish(detached, { type: "ended", reason: "evicted" });
|
|
389
397
|
}
|
|
390
398
|
|
|
391
|
-
const session = this.launch(agent, resolved.companionPath, size);
|
|
399
|
+
const session = this.launch(agent, resolved.companionPath, size, resolved);
|
|
392
400
|
this.attachTo(connection, session, size);
|
|
393
401
|
}
|
|
394
402
|
|
|
@@ -396,6 +404,7 @@ export class TerminalRegistry {
|
|
|
396
404
|
agent: TerminalAgent,
|
|
397
405
|
companionPath: string,
|
|
398
406
|
size: { cols: number; rows: number },
|
|
407
|
+
extras: { agentArgs?: string[]; notice?: string } = {},
|
|
399
408
|
): Session {
|
|
400
409
|
const env: Record<string, string> = {};
|
|
401
410
|
for (const [key, value] of Object.entries(this.options.env ?? process.env)) {
|
|
@@ -413,6 +422,7 @@ export class TerminalRegistry {
|
|
|
413
422
|
"--companion",
|
|
414
423
|
"--yes",
|
|
415
424
|
...(this.options.noGit ? ["--no-git"] : []),
|
|
425
|
+
...(extras.agentArgs ?? []),
|
|
416
426
|
];
|
|
417
427
|
|
|
418
428
|
const session = {
|
|
@@ -427,6 +437,7 @@ export class TerminalRegistry {
|
|
|
427
437
|
ending: null,
|
|
428
438
|
exited: false,
|
|
429
439
|
} as Omit<Session, "process"> as Session;
|
|
440
|
+
if (extras.notice) session.ring.push(new TextEncoder().encode(`${extras.notice}\r\n`));
|
|
430
441
|
session.process = this.spawn({
|
|
431
442
|
argv,
|
|
432
443
|
cwd: companionPath,
|
|
@@ -120,6 +120,11 @@ 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
|
+
|
|
123
128
|
export interface FrameworkConfig {
|
|
124
129
|
type?: FrameworkType;
|
|
125
130
|
git?: GitModeProfile;
|
|
@@ -131,6 +136,7 @@ export interface FrameworkConfig {
|
|
|
131
136
|
cliTools?: CliToolConfig[];
|
|
132
137
|
packageManagers?: string[];
|
|
133
138
|
engines?: EngineConstraints;
|
|
139
|
+
studio?: { terminal?: StudioTerminalConfig };
|
|
134
140
|
}
|
|
135
141
|
|
|
136
142
|
export interface CompanionRegistryConfig {
|
|
@@ -5,14 +5,8 @@ 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
|
-
|
|
10
|
-
isPluginAllowed,
|
|
11
|
-
PluginPolicyError,
|
|
12
|
-
readPluginPolicy,
|
|
13
|
-
} from "./policy";
|
|
14
|
-
|
|
15
|
-
/** Raised before anything is installed: policy, manifest or lockfile inputs are unusable. */
|
|
8
|
+
|
|
9
|
+
/** Raised before anything is installed: manifest or lockfile inputs are unusable. */
|
|
16
10
|
export class FrozenInstallError extends Error {}
|
|
17
11
|
|
|
18
12
|
export interface FrozenInstallDeps {
|
|
@@ -112,8 +106,7 @@ async function treeMismatches(workspaceRoot: string, lock: Lockfile): Promise<st
|
|
|
112
106
|
|
|
113
107
|
/**
|
|
114
108
|
* Deployment restore: installs exactly what the committed lockfile records.
|
|
115
|
-
* Inputs are validated before npm runs — the
|
|
116
|
-
* workspace manifest against the declarations, and the lockfile against the
|
|
109
|
+
* Inputs are validated before npm runs — the workspace manifest against the declarations, and the lockfile against the
|
|
117
110
|
* manifest — and neither tracked file is ever rewritten. An installed tree is
|
|
118
111
|
* reused only when it matches the lockfile's versions and integrity.
|
|
119
112
|
*/
|
|
@@ -127,20 +120,6 @@ export async function installDeclaredPluginsFrozen(
|
|
|
127
120
|
const desired: Record<string, string> = {};
|
|
128
121
|
for (const declaration of sorted) desired[declaration.package] = declaration.version;
|
|
129
122
|
|
|
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
|
-
|
|
144
123
|
const manifestFile = path.join(workspaceRoot, "package.json");
|
|
145
124
|
const lockFile = path.join(workspaceRoot, "package-lock.json");
|
|
146
125
|
const [manifestText, lockText] = await Promise.all([readText(manifestFile), readText(lockFile)]);
|
|
@@ -14,12 +14,6 @@ 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";
|
|
23
17
|
|
|
24
18
|
interface PluginManifest {
|
|
25
19
|
mate?: { pluginApiVersion?: unknown };
|
|
@@ -53,16 +47,6 @@ export async function loadDynamicPlugin(
|
|
|
53
47
|
const name = declaration.package;
|
|
54
48
|
const packageRoot = pluginPackageRoot(companionPath, name);
|
|
55
49
|
|
|
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
|
-
|
|
66
50
|
let manifest: PluginManifest;
|
|
67
51
|
try {
|
|
68
52
|
manifest = JSON.parse(
|
|
@@ -5,12 +5,6 @@ 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";
|
|
14
8
|
|
|
15
9
|
export interface PluginVerificationFailure {
|
|
16
10
|
package: string;
|
|
@@ -19,15 +13,14 @@ export interface PluginVerificationFailure {
|
|
|
19
13
|
|
|
20
14
|
export interface PluginInspection {
|
|
21
15
|
failures: PluginVerificationFailure[];
|
|
22
|
-
/** IDs of the capabilities the loaded
|
|
16
|
+
/** IDs of the capabilities the loaded plugins provide. */
|
|
23
17
|
capabilities: string[];
|
|
24
18
|
}
|
|
25
19
|
|
|
26
20
|
/**
|
|
27
|
-
* Strict, installation-free check of every declared plugin:
|
|
21
|
+
* Strict, installation-free check of every declared plugin: installed
|
|
28
22
|
* and loadable with the effective environment. Unlike hydration it fails
|
|
29
|
-
* closed on the first-class problems ordinary commands only warn about.
|
|
30
|
-
* allowlisted packages are imported.
|
|
23
|
+
* closed on the first-class problems ordinary commands only warn about.
|
|
31
24
|
*/
|
|
32
25
|
export async function verifyDeclaredPlugins(
|
|
33
26
|
companionPath: string,
|
|
@@ -43,14 +36,6 @@ export async function inspectDeclaredPlugins(
|
|
|
43
36
|
const env = deps.env ?? process.env;
|
|
44
37
|
const failures: PluginVerificationFailure[] = [];
|
|
45
38
|
const capabilities: string[] = [];
|
|
46
|
-
let policy: ReturnType<typeof readPluginPolicy>;
|
|
47
|
-
try {
|
|
48
|
-
policy = readPluginPolicy(env);
|
|
49
|
-
} catch (error) {
|
|
50
|
-
if (!(error instanceof PluginPolicyError)) throw error;
|
|
51
|
-
return { failures: [{ package: "(allowlist)", reason: error.message }], capabilities };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
39
|
const declarations: PluginDeclaration[] = [];
|
|
55
40
|
for (const entry of await readDeclarations(companionPath)) {
|
|
56
41
|
const { declaration, error } = validateDeclaration(entry);
|
|
@@ -60,10 +45,6 @@ export async function inspectDeclaredPlugins(
|
|
|
60
45
|
|
|
61
46
|
for (const declaration of declarations) {
|
|
62
47
|
const name = declaration.package;
|
|
63
|
-
if (!isPluginAllowed(policy, name)) {
|
|
64
|
-
failures.push({ package: name, reason: disallowedPluginMessage(name) });
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
48
|
// oxlint-disable-next-line no-await-in-loop -- declared order is part of the contract
|
|
68
49
|
const installed = await fs
|
|
69
50
|
.access(pluginPackageRoot(companionPath, name))
|
|
@@ -1,55 +0,0 @@
|
|
|
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
|
-
}
|