mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(studio): plugin packages, extension view slots, and theme tokens
Working snapshot of the studio branch: installable plugin packages (plan ticket, archive, export, install-source permission subject), the extension protocol's view/slot rendering path, desktop theme tokens, and the file-drop surface on the next shell. Grouped as one commit because the pieces share the generated protocol schema and the desktop port they land through.
This commit is contained in:
1 parent
60def0c689
commit
2d006ec456
80 files changed
+4779
-384
No files matched your search
@@ -446,5 +446,28 @@ export const SCRIPT: Beat[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
// A composed view standing next to the composer. It is in the script for the
|
||||
// same reason every other card is: a rendering path nobody sees while
|
||||
// developing is a rendering path that rots.
|
||||
{
|
||||
wait: 200,
|
||||
ev: {
|
||||
kind: "extension_surface",
|
||||
extension: {
|
||||
pluginId: "opengo", surfaceId: "quota", kind: "view",
|
||||
view: {
|
||||
slot: "composer-trailing",
|
||||
body: [
|
||||
{ kind: "row", children: [
|
||||
{ kind: "pip", tone: "ok" },
|
||||
{ kind: "text", value: "套餐 62%", tone: "strong" },
|
||||
{ kind: "text", value: "620/1000 · 7 天后重置", tone: "dim" },
|
||||
] },
|
||||
{ kind: "meter", progress: 0.62 },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ wait: 300, ev: { kind: "turn_done" } },
|
||||
];
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, ProviderCheck, ProviderEdit, ProviderEntry, ProviderProbe, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, McpDraft, McpDraftServer, McpEntry, McpInstallResult, HookCatalog, HookDryRun, HookEntry, MemoryCatalog, MemoryEntry, NetworkProbe, NetworkSettings, McpRisk, SkillCatalog, SkillEntry, WorkspaceInfo, WorkspaceChanges, Attachment, ThemePack } from "./port";
|
||||
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, ProviderCheck, ProviderEdit, ProviderEntry, ProviderProbe, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, McpDraft, McpDraftServer, McpEntry, McpInstallResult, HookCatalog, HookDryRun, HookEntry, MemoryCatalog, MemoryEntry, NetworkProbe, NetworkSettings, McpRisk, WorkspaceInfo, WorkspaceChanges, Attachment, ThemePack } from "./port";
|
||||
import type { WireEvent } from "./wire";
|
||||
import { MockExtensions } from "./mock_ext";
|
||||
import { SCRIPT } from "./fixture";
|
||||
|
||||
|
||||
export class MockPort implements AgentPort {
|
||||
export class MockPort extends MockExtensions implements AgentPort {
|
||||
private listeners = new Set<(ev: WireEvent) => void>();
|
||||
private log: WireEvent[] = [];
|
||||
// What the user has sent, so checkpoints() can mirror one per turn.
|
||||
@@ -126,28 +127,6 @@ export class MockPort implements AgentPort {
|
||||
{ name: "figma", state: "failed", enabled: true, transport: "http", tools: 0, error: "401 unauthorized" },
|
||||
];
|
||||
|
||||
private skillList: SkillEntry[] = [
|
||||
{
|
||||
name: "review", slashName: "review", description: "复核这一轮改动,给出严重度分级",
|
||||
scope: "project", path: ".reasonix/skills/review/SKILL.md", subagent: true, enabled: true,
|
||||
},
|
||||
{ name: "init", slashName: "init", description: "为这个仓库生成一份项目说明", scope: "builtin", enabled: true },
|
||||
{
|
||||
name: "security-review", slashName: "security-review", description: "只读地过一遍安全面",
|
||||
scope: "builtin", subagent: true, readOnly: true, effort: "high", enabled: true,
|
||||
},
|
||||
// No slash name of its own: only model discovery reaches it. This is the
|
||||
// row the old list could not show at all.
|
||||
{
|
||||
name: "release-notes", description: "从提交历史起草发布说明",
|
||||
scope: "global", path: "~/.reasonix/skills/release-notes/SKILL.md", enabled: false,
|
||||
},
|
||||
{
|
||||
name: "deploy-runbook", slashName: "deploy-runbook", description: "只在你点名时跑的部署清单",
|
||||
scope: "project", path: ".reasonix/skills/deploy-runbook/SKILL.md", manual: true, enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
async mcp(): Promise<McpEntry[]> {
|
||||
return this.servers.map((s) => ({ ...s }));
|
||||
}
|
||||
@@ -368,15 +347,6 @@ export class MockPort implements AgentPort {
|
||||
return out;
|
||||
}
|
||||
|
||||
async skills(): Promise<SkillCatalog> {
|
||||
return { implicit: true, skills: this.skillList.map((s) => ({ ...s })) };
|
||||
}
|
||||
|
||||
async setSkillEnabled(name: string, enabled: boolean) {
|
||||
const sk = this.skillList.find((x) => x.name === name);
|
||||
if (sk) sk.enabled = enabled;
|
||||
}
|
||||
|
||||
async versions(): Promise<VersionHub> {
|
||||
return { current: "dev", pinned: "", stalePin: false, latest: "", newer: false, versions: [] };
|
||||
}
|
||||
@@ -395,6 +365,7 @@ export class MockPort implements AgentPort {
|
||||
models: ["deepseek-v4-pro"], default: "deepseek-v4-pro",
|
||||
hasKey: true, inUse: false, preset: false, keyEnv: "DEEPSEEK_API_KEY",
|
||||
canSetVision: false, canWebSearch: true, webSearch: true,
|
||||
canSetThinking: true, sendsThinking: true,
|
||||
},
|
||||
{
|
||||
name: "myrelay", kind: "openai", baseUrl: "https://relay.example.com/v1",
|
||||
@@ -423,6 +394,10 @@ export class MockPort implements AgentPort {
|
||||
this.sources = this.sources.map((p) => (p.name === name ? { ...p, webSearch: on } : p));
|
||||
}
|
||||
|
||||
async setProviderThinking(name: string, on: boolean): Promise<void> {
|
||||
this.sources = this.sources.map((p) => (p.name === name ? { ...p, sendsThinking: on } : p));
|
||||
}
|
||||
|
||||
async editProvider(edit: ProviderEdit): Promise<void> {
|
||||
this.sources = this.sources.map((p) =>
|
||||
p.name === edit.name ? { ...p, models: edit.models, default: edit.default, visionModels: edit.vision } : p,
|
||||
@@ -441,6 +416,11 @@ export class MockPort implements AgentPort {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// Both of these are the shell reporting on itself; the fixture has no shell.
|
||||
onFileDrop(): () => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
async account(): Promise<AccountState> {
|
||||
return { signedIn: false };
|
||||
}
|
||||
@@ -477,8 +457,13 @@ export class MockPort implements AgentPort {
|
||||
await this.setWorkspace((this.state.workspaceRoot ?? "") + " (隔离副本)");
|
||||
}
|
||||
|
||||
async pickFolder() {
|
||||
return "";
|
||||
async openExternal(url: string) {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
// A fixture has no native picker at all, which is what null says.
|
||||
async pickFolder(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A demo shell has no workspace to read and no kernel to ask, so the fixture
|
||||
@@ -733,6 +718,17 @@ export class MockPort implements AgentPort {
|
||||
},
|
||||
];
|
||||
}
|
||||
private slots: Record<string, string> = {};
|
||||
|
||||
async surfaceSlots() {
|
||||
return { ...this.slots };
|
||||
}
|
||||
|
||||
async assignSurface(surface: string, slot: string) {
|
||||
if (slot) this.slots[surface] = slot;
|
||||
else delete this.slots[surface];
|
||||
}
|
||||
|
||||
async activateTheme(id: string) {
|
||||
this.activeTheme = id;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan, SkillCatalog, SkillEntry } from "./port";
|
||||
|
||||
// The extension half of the fixture: skills, and the packages that bring them.
|
||||
// MockPort extends this rather than holding one, so the class still satisfies
|
||||
// AgentPort in a single declaration and the settings pane's whole extension
|
||||
// tab can be developed with no Go process at all.
|
||||
export class MockExtensions {
|
||||
private skillList: SkillEntry[] = [
|
||||
{
|
||||
name: "review", slashName: "review", description: "复核这一轮改动,给出严重度分级",
|
||||
scope: "project", path: ".reasonix/skills/review/SKILL.md", subagent: true, enabled: true,
|
||||
},
|
||||
{ name: "init", slashName: "init", description: "为这个仓库生成一份项目说明", scope: "builtin", enabled: true },
|
||||
{
|
||||
name: "security-review", slashName: "security-review", description: "只读地过一遍安全面",
|
||||
scope: "builtin", subagent: true, readOnly: true, effort: "high", enabled: true,
|
||||
},
|
||||
// No slash name of its own: only model discovery reaches it. This is the
|
||||
// row the old list could not show at all.
|
||||
{
|
||||
name: "release-notes", description: "从提交历史起草发布说明",
|
||||
scope: "global", path: "~/.reasonix/skills/release-notes/SKILL.md", enabled: false,
|
||||
},
|
||||
{
|
||||
name: "deploy-runbook", slashName: "deploy-runbook", description: "只在你点名时跑的部署清单",
|
||||
scope: "project", path: ".reasonix/skills/deploy-runbook/SKILL.md", manual: true, enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Two packages, because the extension page has to read differently for one
|
||||
// that only adds skills and one that also runs a process.
|
||||
private packages: PluginPackage[] = [
|
||||
{
|
||||
name: "review-kit", version: "1.4.0", root: "~/.reasonix/plugins/review-kit",
|
||||
description: "代码评审的技能与命令", source: "github:acme/review-kit",
|
||||
manifestKind: "reasonix", enabled: true,
|
||||
skills: [
|
||||
{ name: "review", description: "按改动范围逐块评审", invocation: "/review-kit:review" },
|
||||
{ name: "risk", description: "只找会出事的那几处", invocation: "/review-kit:risk" },
|
||||
],
|
||||
commands: [{ name: "pr", description: "写一份 PR 说明", invocation: "/review-kit:pr" }],
|
||||
},
|
||||
{
|
||||
name: "notion-bridge", version: "0.9.1", root: "~/.reasonix/plugins/notion-bridge",
|
||||
description: "把 Notion 接进来", source: "https://example.com/notion-bridge",
|
||||
manifestKind: "claude", enabled: false,
|
||||
skills: [{ name: "notes", description: "读写页面", invocation: "/notion-bridge:notes" }],
|
||||
hooks: [{ event: "SessionStart", command: "hooks/sync.sh", description: "开会话时同步一次" }],
|
||||
mcpServers: [{ name: "notion", transport: "stdio", command: "notion-mcp", autoStart: true }],
|
||||
runtime: {
|
||||
command: "bin/bridge", args: ["--serve"],
|
||||
intercepts: ["tool.before"], capabilities: ["interceptors", "ui"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async skills(): Promise<SkillCatalog> {
|
||||
return { implicit: true, skills: this.skillList.map((s) => ({ ...s })) };
|
||||
}
|
||||
|
||||
async setSkillEnabled(name: string, enabled: boolean) {
|
||||
const sk = this.skillList.find((x) => x.name === name);
|
||||
if (sk) sk.enabled = enabled;
|
||||
}
|
||||
|
||||
async plugins(): Promise<PluginPackage[]> {
|
||||
return this.packages.map((p) => ({ ...p }));
|
||||
}
|
||||
|
||||
// The plan is deliberately the loud kind: a package that runs a process, adds
|
||||
// a hook and connects a server is what the confirmation page exists for.
|
||||
async planPlugin(req: PluginInstallRequest): Promise<PluginPlan> {
|
||||
const source = req.source;
|
||||
return {
|
||||
ok: true, status: "planned", applied: false, source, planId: "high:sha256:mock",
|
||||
actions: [
|
||||
{
|
||||
kind: "plugin", action: "install_plugin_package", status: "planned",
|
||||
riskLevel: "high", name: "mock-pack", version: "2.0.0", source,
|
||||
target: "~/.reasonix/plugins/mock-pack", manifestKind: "reasonix",
|
||||
skills: ["draft", "polish"], skillCount: 2, commandCount: 1, hookCount: 1,
|
||||
riskReasons: [
|
||||
"FULL TRUST: declares a runtime process (bin/pack --serve) that runs inside Reasonix",
|
||||
"registers shell hooks that execute during Reasonix sessions",
|
||||
],
|
||||
runtime: {
|
||||
command: "bin/pack", args: ["--serve"], intercepts: ["input.receive"],
|
||||
capabilities: ["interceptors"], fullTrust: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "mcp", action: "install_mcp_server", status: "planned",
|
||||
riskLevel: "medium", name: "pack-docs", transport: "stdio",
|
||||
command: "npx", args: ["-y", "pack-docs-mcp"],
|
||||
env: { PACK_DOCS_TOKEN: "${PACK_DOCS_TOKEN}" },
|
||||
riskReasons: ["starts a process on this machine"],
|
||||
},
|
||||
],
|
||||
next: "Review the plan, then install.",
|
||||
};
|
||||
}
|
||||
|
||||
// Replacing keeps one row rather than adding a second: an update is the same
|
||||
// package arriving again, which is what the confirmation pane just said.
|
||||
async installPlugin(req: PluginInstallRequest): Promise<PluginPlan> {
|
||||
const source = req.source;
|
||||
const name = req.name ?? "mock-pack";
|
||||
this.packages = this.packages.filter((p) => p.name !== name);
|
||||
this.packages.push({
|
||||
name, version: "2.0.0", root: `~/.reasonix/plugins/${name}`,
|
||||
source, manifestKind: "reasonix", enabled: true,
|
||||
skills: [
|
||||
{ name: "draft", description: "起草", invocation: `/${name}:draft` },
|
||||
{ name: "polish", description: "润色", invocation: `/${name}:polish` },
|
||||
],
|
||||
hooks: [{ event: "SessionStart", command: "hooks/boot.sh" }],
|
||||
});
|
||||
return { ok: true, status: "done", applied: true, source, planId: "high:sha256:mock", actions: [] };
|
||||
}
|
||||
|
||||
async setPluginEnabled(name: string, enabled: boolean) {
|
||||
const p = this.packages.find((x) => x.name === name);
|
||||
if (p) p.enabled = enabled;
|
||||
}
|
||||
|
||||
async removePlugin(name: string): Promise<PluginPlan> {
|
||||
this.packages = this.packages.filter((p) => p.name !== name);
|
||||
return { ok: true, status: "done", applied: true, actions: [] };
|
||||
}
|
||||
|
||||
// Nothing is written in the fixture; what matters here is that the caller
|
||||
// still learns which values an export would have stripped.
|
||||
async exportPlugin(name: string): Promise<PluginExport> {
|
||||
const p = this.packages.find((x) => x.name === name);
|
||||
return { required: p?.mcpServers?.length ? [`${p.name.toUpperCase()}_TOKEN`] : [] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// A plugin package as the settings pane and the install flow read it: what
|
||||
// one contributes, and the plan that describes letting it in.
|
||||
// One thing a package contributes. invocation is how the user reaches it,
|
||||
// already namespaced by the package — empty where there is no way to call it
|
||||
// by name (a prompt template, a theme).
|
||||
export interface PluginItem {
|
||||
name: string;
|
||||
description?: string;
|
||||
invocation?: string;
|
||||
}
|
||||
|
||||
export interface PluginHook {
|
||||
event: string;
|
||||
match?: string;
|
||||
command?: string;
|
||||
contextFile?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface PluginServer {
|
||||
name: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
transport?: string;
|
||||
command?: string;
|
||||
url?: string;
|
||||
autoStart?: boolean;
|
||||
}
|
||||
|
||||
// A runtime process runs inside Reasonix with the user's full trust: it reads
|
||||
// the session, and what it intercepts or replaces it decides for itself.
|
||||
export interface PluginRuntime {
|
||||
command: string;
|
||||
args?: string[];
|
||||
intercepts?: string[];
|
||||
replaces?: string[];
|
||||
capabilities?: string[];
|
||||
}
|
||||
|
||||
export interface PluginSkipped {
|
||||
capability: string;
|
||||
path?: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// One installed package. The first five lists are contributions — things the
|
||||
// package adds to a menu. hooks, mcpServers and runtime are code it brought
|
||||
// with it, and the difference is the whole reason they are separate fields.
|
||||
export interface PluginPackage {
|
||||
name: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
source?: string;
|
||||
root: string;
|
||||
manifestKind?: string;
|
||||
enabled: boolean;
|
||||
status?: string;
|
||||
statusReason?: string;
|
||||
compatibility?: string;
|
||||
skipped?: PluginSkipped[];
|
||||
skills?: PluginItem[];
|
||||
agents?: PluginItem[];
|
||||
commands?: PluginItem[];
|
||||
prompts?: PluginItem[];
|
||||
themes?: PluginItem[];
|
||||
hooks?: PluginHook[];
|
||||
mcpServers?: PluginServer[];
|
||||
runtime?: PluginRuntime;
|
||||
warnings?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// One step of an install plan. riskLevel is the kernel's own grading — the
|
||||
// confirmation page groups by it rather than deciding for itself what is
|
||||
// dangerous, so the two can never disagree.
|
||||
export interface PluginAction {
|
||||
kind: string;
|
||||
action: string;
|
||||
status: string;
|
||||
riskLevel: string;
|
||||
riskReasons?: string[];
|
||||
name?: string;
|
||||
source?: string;
|
||||
target?: string;
|
||||
version?: string;
|
||||
manifestKind?: string;
|
||||
compatibility?: string;
|
||||
skippedCapabilities?: PluginSkipped[];
|
||||
skills?: string[];
|
||||
skillCount?: number;
|
||||
agents?: string[];
|
||||
agentCount?: number;
|
||||
commands?: string[];
|
||||
commandCount?: number;
|
||||
hookCount?: number;
|
||||
promptCount?: number;
|
||||
themeCount?: number;
|
||||
toolCount?: number;
|
||||
transport?: string;
|
||||
url?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
runtime?: PluginRuntime & { fullTrust: boolean };
|
||||
warnings?: string[];
|
||||
error?: string;
|
||||
next?: string;
|
||||
}
|
||||
|
||||
// The same object answers a preview and an apply; status says which happened —
|
||||
// planned | done | partial | failed | blocked | denied.
|
||||
export interface PluginPlan {
|
||||
ok: boolean;
|
||||
status: string;
|
||||
applied: boolean;
|
||||
source?: string;
|
||||
planId?: string;
|
||||
actions?: PluginAction[];
|
||||
warnings?: string[];
|
||||
error?: string;
|
||||
next?: string;
|
||||
// The package landed but the running session could not be rebuilt — almost
|
||||
// always because a turn is in flight. Not a failed install.
|
||||
reloadError?: string;
|
||||
}
|
||||
|
||||
// What an install asks for. Updating an installed package is the same request
|
||||
// with its recorded source and replace set — not a third verb — so what the
|
||||
// user confirms is a plan in the same shape either way.
|
||||
export interface PluginInstallRequest {
|
||||
source: string;
|
||||
name?: string;
|
||||
replace?: boolean;
|
||||
// Present only on an apply: the plan the user just read.
|
||||
planId?: string;
|
||||
}
|
||||
|
||||
// required names the environment variables the archive no longer carries: an
|
||||
// export strips every literal value out of a package's MCP and runtime config,
|
||||
// so whoever installs it supplies these. savedTo is where the shell put the
|
||||
// file; empty in a browser tab, which hands it to the download list instead.
|
||||
export interface PluginExport {
|
||||
required: string[];
|
||||
savedTo?: string;
|
||||
}
|
||||
@@ -26,6 +26,9 @@ export class HttpError extends Error {
|
||||
}
|
||||
|
||||
import type { WireEvent } from "./wire";
|
||||
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
|
||||
|
||||
export type * from "./plugin";
|
||||
|
||||
// GET /history returns the provider conversation, not the event stream: the
|
||||
// stream is live-only, so a reload rebuilds the transcript from these.
|
||||
@@ -169,6 +172,9 @@ export interface ThemePack {
|
||||
tokens: { light?: Record<string, string>; dark?: Record<string, string> };
|
||||
background?: ThemeBackground;
|
||||
hasPreview?: boolean;
|
||||
// Tokens the kernel dropped and why: an unknown name, or a value that could
|
||||
// not be let into a stylesheet. The pack still loads without them.
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface McpEntry {
|
||||
@@ -391,6 +397,11 @@ export interface ProviderEntry {
|
||||
// at all; webSearch whether it is on. They differ between an account's doors.
|
||||
canWebSearch?: boolean;
|
||||
webSearch?: boolean;
|
||||
// Whether thinking/reasoning_effort may go on the wire. canSetThinking is
|
||||
// false where the protocol never carries them, so the switch appears only
|
||||
// where a relay can actually reject the request over it.
|
||||
canSetThinking?: boolean;
|
||||
sendsThinking?: boolean;
|
||||
// Removing the one in use would leave the session on a model that no longer
|
||||
// resolves, so the row offers no delete.
|
||||
inUse: boolean;
|
||||
@@ -475,6 +486,25 @@ export interface AgentPort {
|
||||
// Persisted, but the running session keeps the prompt index it was built
|
||||
// with: the switch reaches the model on the next rebuild, not this turn.
|
||||
setSkillEnabled(name: string, enabled: boolean): Promise<void>;
|
||||
plugins(): Promise<PluginPackage[]>;
|
||||
// Two calls, because what a package brings has to be looked at before it is
|
||||
// let in: plan writes nothing, and install echoes the plan's id back so a
|
||||
// source cannot describe one install and perform another. An update is the
|
||||
// same pair with replace set — a new version can bring a hook the old one
|
||||
// did not have, and that is exactly what the second look is for.
|
||||
planPlugin(req: PluginInstallRequest): Promise<PluginPlan>;
|
||||
installPlugin(req: PluginInstallRequest): Promise<PluginPlan>;
|
||||
setPluginEnabled(name: string, enabled: boolean): Promise<void>;
|
||||
removePlugin(name: string): Promise<PluginPlan>;
|
||||
// Hands the packed package to the user and reports what was stripped out of
|
||||
// it on the way. Installing is the same door: a folder, a link, or this
|
||||
// archive unpacked — there is no separate import.
|
||||
exportPlugin(name: string): Promise<PluginExport>;
|
||||
// Absolute paths of files dropped on an element that opted in with
|
||||
// `--wails-drop-target: drop`. A browser tab only ever sees a File object,
|
||||
// never a path, so this returns an unsubscribe that fires nowhere there —
|
||||
// the same shape onUpdateProgress uses for a shell-only signal.
|
||||
onFileDrop(cb: (paths: string[]) => void): () => void;
|
||||
hooks(): Promise<HookCatalog>;
|
||||
// Replaces one scope wholesale: a client that merges partial edits wrong
|
||||
// silently drops somebody else's rule.
|
||||
@@ -520,6 +550,7 @@ export interface AgentPort {
|
||||
// drop the per-model prices and effort lists it cannot show.
|
||||
editProvider(edit: ProviderEdit): Promise<void>;
|
||||
setProviderWebSearch(name: string, on: boolean): Promise<void>;
|
||||
setProviderThinking(name: string, on: boolean): Promise<void>;
|
||||
removeProvider(name: string): Promise<void>;
|
||||
versions(): Promise<VersionHub>;
|
||||
pinVersion(version: string): Promise<void>;
|
||||
@@ -546,6 +577,10 @@ export interface AgentPort {
|
||||
// null where the host has none at all. Cancelling and having no picker are
|
||||
// different answers, and a caller that conflates them asks twice.
|
||||
pickFolder(): Promise<string | null>;
|
||||
// Hands a link to the platform browser. A webview has nowhere to put a new
|
||||
// tab — a target="_blank" click there does nothing at all — and navigating in
|
||||
// place would replace the session with the page.
|
||||
openExternal(url: string): Promise<void>;
|
||||
sessions(): Promise<SessionEntry[]>;
|
||||
resume(path: string): Promise<void>;
|
||||
newSession(): Promise<void>;
|
||||
@@ -585,6 +620,11 @@ export interface AgentPort {
|
||||
// Installed theme packs and which one is active. The list carries every
|
||||
// pack's tokens so a picker can preview without a second request.
|
||||
themes(): Promise<ThemePack[]>;
|
||||
// Where the user put each extension surface, keyed "<pluginId>:<surfaceId>".
|
||||
// It outranks what the extension asked for; an empty slot hands the decision
|
||||
// back rather than hiding the surface.
|
||||
surfaceSlots(): Promise<Record<string, string>>;
|
||||
assignSurface(surface: string, slot: string): Promise<void>;
|
||||
activateTheme(id: string): Promise<void>;
|
||||
// Extension surfaces arrive on the event stream; these carry the user's half
|
||||
// back — an action the card offered, or a published form's values.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AccountState, AgentPort, Completion, DeviceGrant, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, HookCatalog, HookDryRun, HookEntry, MemoryCatalog, NetworkProbe, NetworkSettings, McpDraft, McpDraftServer, McpEntry, McpInstallResult, SkillCatalog, WorkspaceInfo, ThemePack } from "./port";
|
||||
import type { AccountState, AgentPort, Completion, DeviceGrant, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, HookCatalog, HookDryRun, HookEntry, MemoryCatalog, NetworkProbe, NetworkSettings, McpDraft, McpDraftServer, McpEntry, McpInstallResult, PluginExport, PluginInstallRequest, PluginPackage, PluginPlan, SkillCatalog, WorkspaceInfo, ThemePack } from "./port";
|
||||
import { HttpError, type Attachment, type WorkspaceChanges } from "./port";
|
||||
import type { WireEvent } from "./wire";
|
||||
|
||||
@@ -19,6 +19,12 @@ interface WailsUpdateBus {
|
||||
EventsOn(name: string, cb: (data: UpdateProgress) => void): () => void;
|
||||
}
|
||||
|
||||
// Wails' own drop API, which is the only subscriber that honours
|
||||
// --wails-drop-target. Absent in a browser tab, where a page never learns a path.
|
||||
interface WailsFileDropBus {
|
||||
OnFileDrop(cb: (x: number, y: number, paths: string[]) => void, useDropTarget: boolean): void;
|
||||
}
|
||||
|
||||
// Wails publishes bound methods at window.go.<package>.<Struct>.<Method>; the
|
||||
// shell's package is main and the struct is App. Absent in a browser tab.
|
||||
interface WailsBind {
|
||||
@@ -26,9 +32,11 @@ interface WailsBind {
|
||||
main?: {
|
||||
App?: {
|
||||
PickWorkspace?: () => Promise<string>;
|
||||
OpenExternal?: (url: string) => Promise<void>;
|
||||
Versions?: () => Promise<VersionHub>;
|
||||
PinVersion?: (version: string) => Promise<void>;
|
||||
GoToVersion?: (version: string) => Promise<void>;
|
||||
SavePluginExport?: (name: string) => Promise<{ path: string; required: string[] }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -37,6 +45,9 @@ interface WailsBind {
|
||||
export class SsePort implements AgentPort {
|
||||
constructor(private readonly base = "") {}
|
||||
|
||||
private readonly dropSubs = new Set<(paths: string[]) => void>();
|
||||
private dropWired = false;
|
||||
|
||||
private async post(path: string, body?: unknown): Promise<void> {
|
||||
const res = await fetch(this.base + path, {
|
||||
method: "POST",
|
||||
@@ -98,6 +109,56 @@ export class SsePort implements AgentPort {
|
||||
return this.post("/skills/enabled", { name, enabled });
|
||||
}
|
||||
|
||||
plugins() {
|
||||
return this.get<PluginPackage[]>("/plugins");
|
||||
}
|
||||
|
||||
planPlugin(req: PluginInstallRequest) {
|
||||
return this.post0<PluginPlan>("/plugins/plan", req);
|
||||
}
|
||||
|
||||
installPlugin(req: PluginInstallRequest) {
|
||||
return this.post0<PluginPlan>("/plugins/install", req);
|
||||
}
|
||||
|
||||
async setPluginEnabled(name: string, enabled: boolean) {
|
||||
await this.post0<{ reloadError?: string }>("/plugins/enabled", { name, enabled });
|
||||
}
|
||||
|
||||
async removePlugin(name: string): Promise<PluginPlan> {
|
||||
const res = await fetch(this.base + "/plugins/" + encodeURIComponent(name), {
|
||||
method: "DELETE",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const body = (await res.json().catch(() => ({}))) as PluginPlan & { error?: string };
|
||||
if (!res.ok) throw new Error(body.error || `/plugins/${name}: ${res.status}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
// A webview starts no downloads of its own, so the shell writes the file
|
||||
// through its own save dialog when there is one. In a browser tab the archive
|
||||
// is an ordinary download, and the header is read first because the body is
|
||||
// bytes and has nowhere to say what was stripped out of it.
|
||||
async exportPlugin(name: string): Promise<PluginExport> {
|
||||
const save = (window as WailsBind).go?.main?.App?.SavePluginExport;
|
||||
if (save) {
|
||||
const out = await save(name);
|
||||
return { required: out.required ?? [], savedTo: out.path || undefined };
|
||||
}
|
||||
const res = await fetch(this.base + "/plugins/" + encodeURIComponent(name) + "/export", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) throw new Error(`/plugins/${name}/export: ${res.status}`);
|
||||
const required = (res.headers.get("X-Reasonix-Required-Env") ?? "").split(",").filter(Boolean);
|
||||
const url = URL.createObjectURL(await res.blob());
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${name}.zip`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
return { required };
|
||||
}
|
||||
|
||||
hooks() {
|
||||
return this.get<HookCatalog>("/hooks");
|
||||
}
|
||||
@@ -244,6 +305,10 @@ export class SsePort implements AgentPort {
|
||||
return this.post("/providers/websearch", { name, on });
|
||||
}
|
||||
|
||||
setProviderThinking(name: string, on: boolean) {
|
||||
return this.post("/providers/thinking", { name, on });
|
||||
}
|
||||
|
||||
roles() {
|
||||
return this.get<RoleAssignments>("/roles");
|
||||
}
|
||||
@@ -293,6 +358,23 @@ export class SsePort implements AgentPort {
|
||||
return bus.EventsOn(WAILS_UPDATE_EVENT, cb);
|
||||
}
|
||||
|
||||
// Wails registers its drop listeners once and ignores a second call, so the
|
||||
// one subscription is fanned out here. useDropTarget=true is what keeps this
|
||||
// to elements that opted in: without it every drop in the window arrives,
|
||||
// including an image the composer is about to attach.
|
||||
onFileDrop(cb: (paths: string[]) => void): () => void {
|
||||
const rt = (window as unknown as { runtime?: WailsFileDropBus }).runtime;
|
||||
if (!rt?.OnFileDrop) return () => {};
|
||||
if (!this.dropWired) {
|
||||
this.dropWired = true;
|
||||
rt.OnFileDrop((_x, _y, paths) => this.dropSubs.forEach((f) => f(paths ?? [])), true);
|
||||
}
|
||||
this.dropSubs.add(cb);
|
||||
return () => {
|
||||
this.dropSubs.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
account() {
|
||||
return this.get<AccountState>("/account");
|
||||
}
|
||||
@@ -328,9 +410,19 @@ export class SsePort implements AgentPort {
|
||||
return this.post("/workspace", { isolate: true });
|
||||
}
|
||||
|
||||
async pickFolder() {
|
||||
async openExternal(url: string) {
|
||||
const bind = (window as unknown as WailsBind).go?.main?.App?.OpenExternal;
|
||||
if (bind) {
|
||||
await bind(url);
|
||||
return;
|
||||
}
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
async pickFolder(): Promise<string | null> {
|
||||
const bind = (window as unknown as WailsBind).go?.main?.App?.PickWorkspace;
|
||||
return bind ? ((await bind()) ?? "") : "";
|
||||
if (!bind) return null;
|
||||
return (await bind()) ?? "";
|
||||
}
|
||||
|
||||
sessions() {
|
||||
@@ -448,6 +540,15 @@ export class SsePort implements AgentPort {
|
||||
themes() {
|
||||
return this.get<ThemePack[]>("/themes");
|
||||
}
|
||||
async surfaceSlots() {
|
||||
const r = await this.get<{ slots?: Record<string, string> }>("/surfaces");
|
||||
return r.slots ?? {};
|
||||
}
|
||||
|
||||
assignSurface(surface: string, slot: string) {
|
||||
return this.post("/surfaces", { surface, slot });
|
||||
}
|
||||
|
||||
activateTheme(id: string) {
|
||||
return this.post("/themes", { id });
|
||||
}
|
||||
|
||||
@@ -222,17 +222,41 @@ export interface ExtensionNotification {
|
||||
severity?: string;
|
||||
}
|
||||
|
||||
// A view is composed rather than filled in: the extension sends a tree of
|
||||
// primitives and this side renders them with its own components. Tone says
|
||||
// what a node means, never what colour it is — the palette stays ours.
|
||||
export type ExtensionViewTone = "dim" | "strong" | "ok" | "warn" | "err" | "accent";
|
||||
|
||||
export interface ExtensionViewNode {
|
||||
kind: "text" | "markdown" | "row" | "stack" | "kv" | "meter" | "pip" | "button" | "divider";
|
||||
value?: string;
|
||||
key?: string;
|
||||
label?: string;
|
||||
tone?: ExtensionViewTone;
|
||||
progress?: number;
|
||||
actionId?: string;
|
||||
children?: ExtensionViewNode[];
|
||||
}
|
||||
|
||||
export interface ExtensionView {
|
||||
// Where the extension would like to stand. A name we do not know renders
|
||||
// where we put views we have no place for, rather than not at all.
|
||||
slot?: string;
|
||||
body: ExtensionViewNode[];
|
||||
}
|
||||
|
||||
export interface ExtensionSurface {
|
||||
pluginId: string;
|
||||
surfaceId: string;
|
||||
sessionId?: string;
|
||||
generation?: number;
|
||||
kind: "status" | "card" | "form" | "notification" | "panel";
|
||||
kind: "status" | "card" | "form" | "notification" | "panel" | "view";
|
||||
status?: ExtensionStatus;
|
||||
card?: ExtensionCard;
|
||||
form?: ExtensionForm;
|
||||
notification?: ExtensionNotification;
|
||||
panel?: ExtensionPanel;
|
||||
view?: ExtensionView;
|
||||
}
|
||||
|
||||
export interface Compaction {
|
||||
|
||||
@@ -64,6 +64,11 @@ export interface SessionState {
|
||||
// a state that is still true, so they hold a place in the side rail instead
|
||||
// of scrolling away in the transcript.
|
||||
panels: ExtensionSurface[];
|
||||
// Composed views. A view is a standing surface by definition — it describes
|
||||
// something that is still true — so it never joins the transcript, and where
|
||||
// it is drawn is decided at render time rather than here. That is what lets
|
||||
// the user move one without any of this having to be re-sorted.
|
||||
views: ExtensionSurface[];
|
||||
}
|
||||
|
||||
export const initialState: SessionState = {
|
||||
@@ -77,6 +82,7 @@ export const initialState: SessionState = {
|
||||
doing: "空闲",
|
||||
steerQueue: [],
|
||||
panels: [],
|
||||
views: [],
|
||||
};
|
||||
|
||||
let seq = 0;
|
||||
@@ -365,6 +371,13 @@ export function reduce(
|
||||
panels[at] = ext;
|
||||
return { ...s, panels };
|
||||
}
|
||||
if (ext.kind === "view") {
|
||||
const at = s.views.findIndex((v) => v.pluginId === ext.pluginId && v.surfaceId === ext.surfaceId);
|
||||
if (at < 0) return { ...s, views: [...s.views, ext] };
|
||||
const views = s.views.slice();
|
||||
views[at] = ext;
|
||||
return { ...s, views };
|
||||
}
|
||||
const at = s.items.findIndex(
|
||||
(it) => it.t === "extension" && it.ext.pluginId === ext.pluginId && it.ext.surfaceId === ext.surfaceId,
|
||||
);
|
||||
|
||||
@@ -110,8 +110,13 @@
|
||||
.isolab[hidden] { display: none }
|
||||
.crumb-btn[aria-expanded="true"] .cv { transform: rotate(180deg) }
|
||||
|
||||
/* A picker is as long as the user's config: [[providers]] has no ceiling, so
|
||||
one gateway account can list a hundred models. Without a cap the tail of
|
||||
the list renders past the window edge with no way to reach it — the model
|
||||
menu opens upward, so it is the head that goes. */
|
||||
.menu {
|
||||
position: absolute; z-index: 30; min-width: 244px; padding: 5px;
|
||||
max-height: min(58vh, 460px); overflow-y: auto; overscroll-behavior: contain;
|
||||
border: 1px solid var(--border); border-radius: var(--r-md); background: var(--raised);
|
||||
box-shadow: 0 14px 34px -12px rgba(0, 0, 0, .5);
|
||||
animation: panel var(--t-panel) var(--ease-out) both;
|
||||
@@ -138,6 +143,21 @@
|
||||
.mi[data-on] .rt { color: var(--accent) }
|
||||
.menu .div { height: 1px; margin: 5px 4px; background: var(--hair) }
|
||||
.mi.plain .lb { font-weight: 400; color: var(--muted) }
|
||||
|
||||
/* Sticky because the rows are the scrolling part: a field that scrolls away
|
||||
takes the query out of sight while its result is still on screen. */
|
||||
.mfind {
|
||||
position: sticky; top: -5px; z-index: 1; width: calc(100% + 10px);
|
||||
margin: -5px -5px 5px; padding: 9px 11px;
|
||||
border: 0; border-bottom: 1px solid var(--hair); border-radius: var(--r-md) var(--r-md) 0 0;
|
||||
background: var(--raised); color: var(--text); font: 400 12.5px var(--ui);
|
||||
}
|
||||
.mfind:focus { outline: none }
|
||||
.mfind::placeholder { color: var(--ghost) }
|
||||
/* What Enter takes while the field still holds focus. Deliberately the same
|
||||
weight as hover: it is a standing offer, not a selection. */
|
||||
.mi[data-lead] { background: var(--overlay) }
|
||||
.mnone { padding: 9px 10px; font: 400 11.5px var(--ui); color: var(--faint) }
|
||||
.mi.plain .dot { background: transparent } /* 它不是一个可选中的项目 */
|
||||
|
||||
/* 斜杠补全补的是正在打的那一行,所以它贴着输入框上沿,宽度也跟着输入框 */
|
||||
@@ -1308,18 +1328,46 @@
|
||||
}
|
||||
.confirm .q { flex: 1; font: 400 11.5px/1.55 var(--ui); color: var(--text) }
|
||||
|
||||
/* 接入流程:先粘,再确认,最后说结果。三步共用一块地方,靠 data-stage 换内容 */
|
||||
.addsrv { border-top: 1px solid var(--hair); padding: 12px }
|
||||
.addsrv .paste {
|
||||
/* 接入流程:先粘,再确认,最后说结果。三步共用一块地方,靠 data-stage 换内容。
|
||||
插件包走的是同一条路(来源 → 计划 → 结果),所以它们是同一个版式。 */
|
||||
.addsrv, .addpkg { border-top: 1px solid var(--hair); padding: 12px }
|
||||
.addsrv .paste, .addpkg .paste {
|
||||
width: 100%; padding: 9px 11px; border: 1px solid var(--border); border-radius: var(--r-sm);
|
||||
background: var(--raised); color: var(--text); font: 400 11.5px/1.7 var(--mono); resize: vertical;
|
||||
}
|
||||
.addsrv .paste::placeholder { color: var(--ghost); white-space: pre-line }
|
||||
.addsrv .paste:focus { outline: 2px solid var(--focus); outline-offset: 1px }
|
||||
.addsrv .acts { display: flex; align-items: center; gap: 8px; margin-top: 10px }
|
||||
.addsrv .acts .note { flex: 1; font: 400 10.5px var(--ui); color: var(--ghost) }
|
||||
.addsrv .act[data-primary] { border-color: var(--accent); background: var(--accent); color: var(--accent-fg) }
|
||||
.addsrv .act[data-primary]:disabled { border-color: var(--border); background: transparent; color: var(--ghost) }
|
||||
.addsrv .paste::placeholder, .addpkg .paste::placeholder { color: var(--ghost); white-space: pre-line }
|
||||
.addsrv .paste:focus, .addpkg .paste:focus { outline: 2px solid var(--focus); outline-offset: 1px }
|
||||
.addsrv .acts, .addpkg .acts { display: flex; align-items: center; gap: 8px; margin-top: 10px }
|
||||
.addsrv .acts .note, .addpkg .acts .note { flex: 1; font: 400 10.5px var(--ui); color: var(--ghost) }
|
||||
.addsrv .act[data-primary], .addpkg .act[data-primary] { border-color: var(--accent); background: var(--accent); color: var(--accent-fg) }
|
||||
.addsrv .act[data-primary]:disabled, .addpkg .act[data-primary]:disabled { border-color: var(--border); background: transparent; color: var(--ghost) }
|
||||
.addpkg > .why { margin-top: 9px; font: 400 11.5px/1.55 var(--ui); color: var(--err) }
|
||||
/* 只有这块声明自己收文件:Wails 把路径只交给带这个属性的元素,所以拖图片给
|
||||
输入框的那条既有路径不受影响。属性会继承,落在里面的 textarea 上也算。
|
||||
wails-drop-target-active 是运行时在悬停时挂上的类。 */
|
||||
.addpkg[data-stage="paste"] { --wails-drop-target: drop }
|
||||
.addpkg.wails-drop-target-active .paste { border-color: var(--accent); background: var(--overlay) }
|
||||
|
||||
/* 计划按风险分组:会跑东西的那一组自带警戒线,剩下的只是文件。
|
||||
等级是核心判的,这里只负责让它一眼可见。 */
|
||||
.rgrp { margin-top: 10px }
|
||||
.rgrp:first-child { margin-top: 0 }
|
||||
.rgrp h3 { margin: 0; font: 500 11px var(--ui); color: var(--faint) }
|
||||
.rgrp[data-lvl="high"] { padding-left: 9px; box-shadow: inset 2px 0 var(--err) }
|
||||
.rgrp[data-lvl="high"] h3 { color: var(--err) }
|
||||
/* 核心的判定是写给读 JSON 的模型的,所以它在一次点击之后 —— 但它必须在,
|
||||
否则这里不认识的新理由就会静悄悄消失。 */
|
||||
.reasons { margin-top: 7px }
|
||||
.reasons > summary { font: 400 10.5px var(--ui); color: var(--ghost); cursor: pointer; list-style: none }
|
||||
.reasons > summary::-webkit-details-marker { display: none }
|
||||
.reasons p { margin: 4px 0 0; font: 400 10.5px/1.6 var(--mono); color: var(--muted) }
|
||||
|
||||
/* 一个包带进来的东西分两类:能被叫到的(技能、命令),和会自己跑的
|
||||
(常驻进程、钩子、外部服务)。后者不该和前者读起来一样。 */
|
||||
.peek .row[data-run] { color: var(--text) }
|
||||
.peek .row[data-run] .d { color: var(--warn, var(--accent)) }
|
||||
.peek .row .sc { margin-left: auto; padding-left: 12px; color: var(--ghost); font: 400 10.5px var(--ui);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.addsrv > .why { margin-top: 9px; font: 400 11.5px/1.55 var(--ui); color: var(--err) }
|
||||
|
||||
/* 分工带:主模型在上,四个位置挂在它下面的那根轨上。这里不用卡片 ——
|
||||
@@ -1964,12 +2012,42 @@
|
||||
.extfield input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px }
|
||||
.extform[data-sealed] { opacity: .72 }
|
||||
|
||||
/* A palette's own thumbnail in the picker. Fixed box so a row's height does
|
||||
not depend on whether that pack ships a picture. */
|
||||
.packshot {
|
||||
width: 44px; height: 28px; object-fit: cover; flex: none;
|
||||
border-radius: var(--r-xs); border: 1px solid var(--hair);
|
||||
/* A palette is chosen by looking at it, so each card is that palette drawing
|
||||
the window it is about to paint. The pack's own picture is a cover over
|
||||
that, not the card itself: a pack without one, or one whose file fails to
|
||||
load, still shows its colours instead of a broken image. */
|
||||
.palettes { display: grid; grid-template-columns: repeat(auto-fill, minmax(164px, 1fr)); gap: 14px 12px }
|
||||
.pal { display: flex; flex-direction: column; gap: 7px; padding: 0; border: 0; background: none; text-align: left; cursor: pointer }
|
||||
.pal-art {
|
||||
position: relative; display: flex; aspect-ratio: 16 / 10; overflow: hidden;
|
||||
border: 1px solid var(--border); border-radius: var(--r-sm);
|
||||
background: var(--pal-page, var(--page));
|
||||
transition: border-color .16s var(--ease-out), box-shadow .16s var(--ease-out);
|
||||
}
|
||||
.pal:hover .pal-art { border-color: color-mix(in srgb, var(--pal-accent, var(--accent)) 55%, var(--border)) }
|
||||
.pal[data-on] .pal-art { border-color: var(--pal-accent, var(--accent)); box-shadow: 0 0 0 1px var(--pal-accent, var(--accent)) }
|
||||
/* The dot reads over a photo the way a border alone does not. */
|
||||
.pal[data-on] .pal-art::after {
|
||||
content: ""; position: absolute; top: 7px; right: 7px; width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--pal-accent, var(--accent)); box-shadow: 0 0 0 2px var(--pal-page, var(--page));
|
||||
}
|
||||
.pal-rail { flex: none; width: 26px; background: var(--pal-surface, var(--surface)); border-right: 1px solid var(--pal-border, var(--hair)) }
|
||||
.pal-body {
|
||||
flex: 1; display: flex; flex-direction: column; gap: 5px; margin: 10px; padding: 9px 8px;
|
||||
border: 1px solid var(--pal-border, var(--hair)); border-radius: var(--r-xs);
|
||||
background: var(--pal-panel, var(--raised));
|
||||
}
|
||||
.pal-line { height: 4px; border-radius: 2px; background: var(--pal-text, var(--text)); opacity: .78 }
|
||||
.pal-line[data-short] { width: 58%; background: var(--pal-dim, var(--muted)); opacity: .72 }
|
||||
.pal-mark { margin-top: auto; width: 34%; height: 5px; border-radius: 2px; background: var(--pal-accent, var(--accent)) }
|
||||
.pal-shot { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover }
|
||||
/* Name over source, each on one line: side by side they both wrapped, and a
|
||||
grid of two-line-by-two-column labels reads as noise between the cards. */
|
||||
.pal-nm { display: block; min-width: 0; font: 400 11.5px var(--ui); color: var(--muted) }
|
||||
.pal-nm b, .pal-nm em { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.pal-nm b { font-weight: 400 }
|
||||
.pal[data-on] .pal-nm b { color: var(--text) }
|
||||
.pal-nm em { margin-top: 2px; font: 400 10.5px var(--mono); font-style: normal; color: var(--ghost) }
|
||||
|
||||
/* A standing panel sits in the side rail, so it is denser than the same
|
||||
content would be in the transcript and borrows the rail's own row rhythm. */
|
||||
@@ -1981,3 +2059,60 @@
|
||||
.extpanel-tx { font-size: 11.5px; line-height: 1.5; color: var(--muted) }
|
||||
.extpanel .extkv { font-size: 11.5px }
|
||||
.extpanel .btn { padding: 2px 7px; font-size: 11px }
|
||||
|
||||
/* 组合视图:扩展递过来的是一棵原语树,这里用本来就在用的那套外观画它。
|
||||
语气(tone)只说"这是什么",颜色仍然是应用的 —— 扩展说得出"这一项失败了",
|
||||
说不出"失败是绿色的"。 */
|
||||
.xview { display: flex; flex-direction: column; gap: 7px; min-width: 0 }
|
||||
.xv-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0 }
|
||||
.xv-stack { display: flex; flex-direction: column; gap: 6px; min-width: 0 }
|
||||
.xv-t { font: 400 12px var(--ui); color: var(--text); min-width: 0; overflow-wrap: anywhere }
|
||||
.xv-t[data-tone="dim"] { color: var(--faint) }
|
||||
.xv-t[data-tone="strong"] { font-weight: 500 }
|
||||
.xv-t[data-tone="ok"] { color: var(--ok) }
|
||||
.xv-t[data-tone="warn"] { color: var(--warn) }
|
||||
.xv-t[data-tone="err"] { color: var(--err) }
|
||||
.xv-t[data-tone="accent"] { color: var(--accent) }
|
||||
/* 扩展递来的 markdown 按纯文本画:抄本的渲染器假定内容可信,视图不是。 */
|
||||
.xv-md { margin: 0; font: 400 12px/1.6 var(--ui); color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere }
|
||||
.xv-kv { display: flex; align-items: baseline; gap: 9px; font: 400 11.5px var(--ui) }
|
||||
.xv-kv .k { flex: none; min-width: 84px; color: var(--faint) }
|
||||
.xv-kv .v { color: var(--text); font-family: var(--mono); overflow-wrap: anywhere }
|
||||
.xv-meter { position: relative; height: 4px; border-radius: var(--r-pill); background: var(--overlay); overflow: hidden }
|
||||
.xv-meter > i { display: block; height: 100%; background: var(--deleg); transition: width var(--t-card) var(--ease) }
|
||||
.xv-meter .lb { position: absolute; right: 0; top: 6px; font: 400 10.5px var(--ui); color: var(--ghost) }
|
||||
.xv-hr { margin: 2px 0; border: 0; border-top: 1px solid var(--hair) }
|
||||
.xview .pip[data-tone="ok"] { background: var(--ok) }
|
||||
.xview .pip[data-tone="warn"] { background: var(--warn) }
|
||||
.xview .pip[data-tone="err"] { background: var(--err) }
|
||||
.xview .pip[data-tone="accent"] { background: var(--accent) }
|
||||
/* 槽位轨:贴着输入框上沿的一条窄带。它不参与滚动,也不吃输入框的高度 ——
|
||||
插件可以站在这里,不能把这里占满。 */
|
||||
.slotrail {
|
||||
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
|
||||
padding: 4px 2px 6px; max-height: 84px; overflow-y: auto;
|
||||
}
|
||||
.slotrail .xview { flex-direction: row; align-items: center; gap: 8px }
|
||||
/* 移动手柄:宿主给每个视图加的唯一一个控件。它平时不显形 —— 常见动作是读它,
|
||||
不是挪它。菜单里的"交还给插件"和选一个位置是两件事:前者把决定权还回去。 */
|
||||
.slotted { position: relative; display: flex; align-items: center; gap: 6px }
|
||||
.movegrip {
|
||||
flex: none; padding: 0 4px; border: 0; background: none; cursor: pointer;
|
||||
color: transparent; font: 400 12px var(--ui); line-height: 1; transition: color .14s;
|
||||
}
|
||||
.slotted:hover .movegrip, .movegrip:focus-visible { color: var(--ghost) }
|
||||
.movegrip:hover { color: var(--muted) }
|
||||
.movemenu {
|
||||
position: absolute; right: 0; bottom: calc(100% + 4px); z-index: 30;
|
||||
display: flex; flex-direction: column; min-width: 132px; padding: 4px;
|
||||
border: 1px solid var(--border); border-radius: var(--r-sm);
|
||||
background: var(--raised); box-shadow: 0 8px 24px rgb(0 0 0 / .18);
|
||||
}
|
||||
.movemenu .who { padding: 3px 8px 5px; font: 400 10.5px var(--mono); color: var(--ghost) }
|
||||
.movemenu button {
|
||||
padding: 5px 8px; border: 0; border-radius: var(--r-xs); background: none;
|
||||
color: var(--muted); font: 400 12px var(--ui); text-align: left; cursor: pointer;
|
||||
}
|
||||
.movemenu button:hover { background: var(--overlay); color: var(--text) }
|
||||
.movemenu button[aria-checked="true"] { color: var(--text) }
|
||||
.movemenu button[aria-checked="true"]::after { content: " ·" }
|
||||
@@ -0,0 +1,333 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AgentPort, PluginAction, PluginPackage, PluginPlan } from "../port/port";
|
||||
|
||||
// Installing and importing are the same act from two doors, so there is one
|
||||
// box: a link, or a folder picked off this machine.
|
||||
const PLACEHOLDER = `https://github.com/acme/review-kit
|
||||
|
||||
~/projects/my-plugin`;
|
||||
|
||||
const RISK_TITLE: Record<string, string> = {
|
||||
high: "这些会在你机器上跑东西",
|
||||
medium: "这些会改变可用的能力",
|
||||
low: "这些只是加文件",
|
||||
};
|
||||
|
||||
const ORDER = ["high", "medium", "low"];
|
||||
|
||||
interface Props {
|
||||
port: AgentPort;
|
||||
onClose: () => void;
|
||||
onInstalled: () => void;
|
||||
// Set when this is an update: the package being replaced. Its source is
|
||||
// already known, so the box is skipped and the plan is read against what is
|
||||
// installed — a new version's new hook is the thing worth seeing.
|
||||
updating?: PluginPackage;
|
||||
}
|
||||
|
||||
export function AddPlugin({ port, onClose, onInstalled, updating }: Props) {
|
||||
const [text, setText] = useState(updating?.source ?? "");
|
||||
const [plan, setPlan] = useState<PluginPlan | null>(null);
|
||||
const [done, setDone] = useState<PluginPlan | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const request = (planId?: string) => ({
|
||||
source: text.trim(),
|
||||
name: updating?.name,
|
||||
replace: !!updating,
|
||||
planId,
|
||||
});
|
||||
|
||||
const look = async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const p = await port.planPlugin(request());
|
||||
// "Nothing installable here" is the plan's own answer, and its reason is
|
||||
// more useful than anything this component could invent.
|
||||
if (!p.actions?.length) {
|
||||
setError(p.error || p.next || "这个来源里没有能装的东西");
|
||||
setPlan(null);
|
||||
return;
|
||||
}
|
||||
setPlan(p);
|
||||
} catch (e) {
|
||||
setPlan(null);
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const install = async () => {
|
||||
if (!plan) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const out = await port.installPlugin(request(plan.planId));
|
||||
setDone(out);
|
||||
if (out.applied) onInstalled();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pick = async () => {
|
||||
const dir = await port.pickFolder();
|
||||
if (dir) setText(dir);
|
||||
};
|
||||
|
||||
// An update has nothing to type: its source was recorded at install. Going
|
||||
// straight to the plan is what makes the button say "update" and not "fill
|
||||
// in where this came from again".
|
||||
useEffect(() => {
|
||||
if (updating?.source) void look();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// A dropped folder is a source typed for you. It fills the box rather than
|
||||
// planning straight away: dropping is how you point at something, not how
|
||||
// you agree to install it.
|
||||
useEffect(() => port.onFileDrop((paths) => paths[0] && setText(paths[0])), [port]);
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div className="addpkg" data-stage="done">
|
||||
<Outcome plan={done} />
|
||||
<div className="acts">
|
||||
<button className="act" onClick={onClose}>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (plan) {
|
||||
const groups = ORDER.map((level) => ({
|
||||
level,
|
||||
actions: (plan.actions ?? []).filter((a) => (a.riskLevel || "low") === level),
|
||||
})).filter((g) => g.actions.length > 0);
|
||||
const gained = updating ? newlyGained(updating, plan.actions ?? []) : [];
|
||||
return (
|
||||
<div className="addpkg" data-stage="confirm">
|
||||
{/* What this version brings that the installed one did not. A package
|
||||
that was only skills last time and starts a process this time is
|
||||
the case the whole confirmation exists for. */}
|
||||
{updating && (
|
||||
<div className="find" data-lvl={gained.length ? "warn" : undefined}>
|
||||
<span className="t">
|
||||
{version(plan.actions) && updating.version
|
||||
? `${updating.name} ${updating.version} → ${version(plan.actions)}`
|
||||
: `重装 ${updating.name}`}
|
||||
</span>
|
||||
<span className="why">{gained.length ? `这一版新增了:${gained.join("、")}` : "没有新增会执行的东西。"}</span>
|
||||
</div>
|
||||
)}
|
||||
{groups.map((g) => (
|
||||
<section className="rgrp" key={g.level} data-lvl={g.level}>
|
||||
<h3>{RISK_TITLE[g.level] ?? g.level}</h3>
|
||||
{g.actions.map((a, i) => (
|
||||
<Candidate a={a} key={`${a.kind}:${a.name}:${i}`} />
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
{plan.warnings?.map((wmsg) => (
|
||||
<div className="why" key={wmsg}>
|
||||
{wmsg}
|
||||
</div>
|
||||
))}
|
||||
<div className="acts">
|
||||
<span className="note">{updating ? "覆盖已装的那一份" : "装到「我的」,所有项目都能用"}</span>
|
||||
<button className="act" onClick={() => (updating ? onClose() : setPlan(null))}>
|
||||
{updating ? "取消" : "返回"}
|
||||
</button>
|
||||
<button className="act" data-primary disabled={busy} onClick={() => void install()}>
|
||||
{busy ? (updating ? "更新中…" : "安装中…") : updating ? "更新" : "装上"}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="why">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// An update never shows the box: its source is not something to re-enter, and
|
||||
// a failure there is about the recorded source, not about what was typed.
|
||||
if (updating) {
|
||||
return (
|
||||
<div className="addpkg" data-stage="reading">
|
||||
<div className="find" data-lvl={error ? "err" : undefined}>
|
||||
<span className="t">{error ? `读不到 ${updating.name} 的来源` : `正在读取 ${updating.name} 的来源…`}</span>
|
||||
<span className="why">{error || updating.source}</span>
|
||||
</div>
|
||||
<div className="acts">
|
||||
<button className="act" onClick={onClose}>
|
||||
{error ? "关掉" : "取消"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="addpkg" data-stage="paste">
|
||||
<textarea
|
||||
className="paste"
|
||||
rows={3}
|
||||
autoFocus
|
||||
value={text}
|
||||
placeholder={PLACEHOLDER}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") void look();
|
||||
}}
|
||||
/>
|
||||
<div className="acts">
|
||||
<span className="note">一个仓库地址,或者把文件夹拖进来</span>
|
||||
<button className="act" onClick={() => void pick()}>
|
||||
选文件夹
|
||||
</button>
|
||||
<button className="act" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button className="act" data-primary disabled={!text.trim() || busy} onClick={() => void look()}>
|
||||
{busy ? "读取中…" : "看看是什么"}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="why">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The plan's own riskReasons are written for the model that reads the tool's
|
||||
// JSON. What the user needs is the same facts as rows they can scan, so the
|
||||
// structured counts are rendered here and the kernel's wording stays behind a
|
||||
// fold — where a reason this UI does not know about yet is still readable.
|
||||
function Candidate({ a }: { a: PluginAction }) {
|
||||
const meta = [a.version, a.manifestKind, a.transport].filter(Boolean).join(" · ");
|
||||
const runs = executes(a);
|
||||
const adds = contributes(a);
|
||||
return (
|
||||
<div className="cand">
|
||||
<div className="cand-hd">
|
||||
<span className="nm">{a.name || a.kind}</span>
|
||||
{meta && <span className="meta">{meta}</span>}
|
||||
</div>
|
||||
{runs.map((r) => (
|
||||
<div className="risk" data-kind="shell" key={r.label}>
|
||||
<span className="lb">{r.label}</span>
|
||||
<span className="dt">{r.detail}</span>
|
||||
{r.why && <span className="why">{r.why}</span>}
|
||||
</div>
|
||||
))}
|
||||
{adds.length > 0 && (
|
||||
<div className="risk">
|
||||
<span className="lb">会加进来</span>
|
||||
<span className="dt">{adds.join(" · ")}</span>
|
||||
</div>
|
||||
)}
|
||||
{Object.keys(a.env ?? {}).map((k) => (
|
||||
<div className="risk" data-kind="secret" key={k}>
|
||||
<span className="lb">要你填</span>
|
||||
<span className="dt">{k}</span>
|
||||
</div>
|
||||
))}
|
||||
{a.skippedCapabilities?.map((s) => (
|
||||
<div className="risk" key={s.capability + s.reason}>
|
||||
<span className="lb">用不了</span>
|
||||
<span className="dt">{s.capability}</span>
|
||||
<span className="why">{s.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
{a.riskReasons?.length ? (
|
||||
<details className="reasons">
|
||||
<summary>核心给出的判定({a.riskReasons.length})</summary>
|
||||
{a.riskReasons.map((r) => (
|
||||
<p key={r}>{r}</p>
|
||||
))}
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// What will execute. A process, a hook and an external server are each their
|
||||
// own row: "one more skill" and "one more process" are not the same decision,
|
||||
// and a single count would let the second hide inside the first.
|
||||
function executes(a: PluginAction): { label: string; detail: string; why?: string }[] {
|
||||
const out: { label: string; detail: string; why?: string }[] = [];
|
||||
if (a.runtime) {
|
||||
out.push({
|
||||
label: "常驻进程",
|
||||
detail: [a.runtime.command, ...(a.runtime.args ?? [])].join(" "),
|
||||
why: "它跑在 Reasonix 内部,能读整个会话、绕过权限、直接操作这台机器",
|
||||
});
|
||||
}
|
||||
if (a.hookCount) {
|
||||
out.push({ label: "自动化钩子", detail: `${a.hookCount} 条`, why: "会在会话的生命周期里自动执行" });
|
||||
}
|
||||
if (a.toolCount) {
|
||||
out.push({ label: "外部服务", detail: `${a.toolCount} 个 MCP 服务`, why: "它给 agent 的能力和内置工具一样真实" });
|
||||
}
|
||||
if (a.kind === "mcp" && a.command) {
|
||||
out.push({ label: "会启动", detail: [a.command, ...(a.args ?? [])].join(" ") });
|
||||
}
|
||||
if (a.kind === "mcp" && a.url) {
|
||||
out.push({ label: "会连到", detail: a.url });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function version(actions?: PluginAction[]): string {
|
||||
return actions?.find((a) => a.kind === "plugin")?.version ?? "";
|
||||
}
|
||||
|
||||
// newlyGained compares what runs, not what the package totals. Losing a hook is
|
||||
// not something to warn about, and one more skill is not either — the question
|
||||
// an update has to answer is whether it now does something it could not before.
|
||||
function newlyGained(current: PluginPackage, actions: PluginAction[]): string[] {
|
||||
const plugin = actions.find((a) => a.kind === "plugin");
|
||||
const out: string[] = [];
|
||||
if (plugin?.runtime && !current.runtime) out.push("一个常驻进程");
|
||||
const hooks = (plugin?.hookCount ?? 0) - (current.hooks?.length ?? 0);
|
||||
if (hooks > 0) out.push(`${hooks} 条钩子`);
|
||||
const servers = (plugin?.toolCount ?? 0) - (current.mcpServers?.length ?? 0);
|
||||
if (servers > 0) out.push(`${servers} 个外部服务`);
|
||||
// A separate MCP action installs a server of its own, outside the package.
|
||||
const standalone = actions.filter((a) => a.kind === "mcp").length;
|
||||
if (standalone > 0) out.push(`${standalone} 个独立的 MCP 服务`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function contributes(a: PluginAction): string[] {
|
||||
const parts: string[] = [];
|
||||
const add = (n: number | undefined, unit: string) => {
|
||||
if (n) parts.push(`${n} ${unit}`);
|
||||
};
|
||||
add(a.skillCount, "个技能");
|
||||
add(a.commandCount, "个命令");
|
||||
add(a.agentCount, "个子代理");
|
||||
add(a.promptCount, "个提示词");
|
||||
add(a.themeCount, "套配色");
|
||||
return parts;
|
||||
}
|
||||
|
||||
// "Written" and "running" are different outcomes: a reload refused mid-turn
|
||||
// leaves the package on disk and out of this session, and saying so is the
|
||||
// difference between waiting and re-installing.
|
||||
function Outcome({ plan }: { plan: PluginPlan }) {
|
||||
const state = plan.reloadError ? "action_required" : plan.ok ? "ready" : "issue";
|
||||
return (
|
||||
<div className="outcome" data-state={state}>
|
||||
<i className="pip" />
|
||||
<span className="nm">{plan.actions?.[0]?.name || "安装"}</span>
|
||||
<span className="dt">
|
||||
{state === "ready" && "装好了,下一轮就能用"}
|
||||
{state === "action_required" && "装好了,但这一轮还在跑:等它结束或新建会话后生效"}
|
||||
{state === "issue" && (plan.error || plan.next || "没装上")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { Transcript } from "./Transcript";
|
||||
import { Trajectory } from "./Trajectory";
|
||||
import { Composer } from "./Composer";
|
||||
import { RunStrip } from "./RunStrip";
|
||||
import { SlottedView } from "./SlottedView";
|
||||
import { key as slotKey, placement } from "./slots";
|
||||
import { apply as applyThemePack } from "./theme";
|
||||
import { Metrics } from "./Metrics";
|
||||
import { Sessions } from "./Sessions";
|
||||
@@ -212,6 +214,45 @@ export function App({ port }: { port: AgentPort }) {
|
||||
}, [port]);
|
||||
useEffect(reloadThemes, [reloadThemes]);
|
||||
|
||||
// Where the user put each extension surface. Loaded once and updated in
|
||||
// place: a move is the user's own action, so waiting for a round trip to see
|
||||
// it land would read as the control ignoring the click.
|
||||
const [slots, setSlots] = useState<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
void port.surfaceSlots().then(setSlots).catch(() => setSlots({}));
|
||||
}, [port]);
|
||||
const moveSurface = useCallback(
|
||||
async (ext: { pluginId: string; surfaceId: string }, slot: string) => {
|
||||
const id = `${ext.pluginId}:${ext.surfaceId}`;
|
||||
setSlots((prev) => {
|
||||
const next = { ...prev };
|
||||
if (slot) next[id] = slot;
|
||||
else delete next[id];
|
||||
return next;
|
||||
});
|
||||
await port.assignSurface(id, slot).catch(fail);
|
||||
},
|
||||
[port, fail],
|
||||
);
|
||||
const atComposer = s.views.filter((v) => placement(v, slots) === "composer-trailing");
|
||||
const inRail = s.views.filter((v) => placement(v, slots) !== "composer-trailing");
|
||||
|
||||
// A webview has nowhere to put a new tab, so target="_blank" opens nothing at
|
||||
// all, and letting the link navigate in place would replace the session with
|
||||
// the page. Every link leaves through the host instead.
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (e.defaultPrevented || e.button !== 0) return;
|
||||
const link = (e.target as Element | null)?.closest?.("a[href]");
|
||||
const href = link?.getAttribute("href") ?? "";
|
||||
if (!/^https?:\/\//i.test(href)) return;
|
||||
e.preventDefault();
|
||||
void port.openExternal(href).catch(fail);
|
||||
};
|
||||
addEventListener("click", onClick);
|
||||
return () => removeEventListener("click", onClick);
|
||||
}, [port, fail]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "\\") {
|
||||
@@ -404,6 +445,22 @@ export function App({ port }: { port: AgentPort }) {
|
||||
<i />
|
||||
</span>
|
||||
<RunStrip doing={s.doing} metrics={s.metrics} steps={steps} elapsed={elapsed} />
|
||||
{/* Views the user (or the extension) put next to the composer.
|
||||
They sit above it rather than inside it: the input box is the
|
||||
one thing an extension must never be able to crowd out. */}
|
||||
{atComposer.length > 0 && (
|
||||
<div className="slotrail">
|
||||
{atComposer.map((ext) => (
|
||||
<SlottedView
|
||||
key={slotKey(ext)}
|
||||
ext={ext}
|
||||
assigned={slots}
|
||||
onAction={(id) => void port.invokeExtensionAction(id).catch(fail)}
|
||||
onMove={(slot) => void moveSurface(ext, slot)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Composer
|
||||
port={port}
|
||||
status={status}
|
||||
@@ -435,6 +492,8 @@ export function App({ port }: { port: AgentPort }) {
|
||||
onFold={() => setSide(false)}
|
||||
onSettings={() => setSettings(true)}
|
||||
panels={s.panels}
|
||||
views={inRail}
|
||||
onMoveSurface={moveSurface}
|
||||
onExtInvoke={onExtInvoke}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useCallback, useEffect, useState, type CSSProperties } from "react";
|
||||
import type { AgentPort, ThemePack } from "../port/port";
|
||||
|
||||
// Exported because the nav rail names the current one next to the tab.
|
||||
export const SCHEMES: [string, string][] = [
|
||||
["auto", "跟随系统"],
|
||||
["light", "浅色"],
|
||||
["dark", "深色"],
|
||||
];
|
||||
|
||||
interface Props {
|
||||
port: AgentPort;
|
||||
theme: string;
|
||||
onTheme: (t: string) => void;
|
||||
reloadThemes: () => void;
|
||||
}
|
||||
|
||||
export function Appearance({ port, theme, onTheme, reloadThemes }: Props) {
|
||||
const [packs, setPacks] = useState<ThemePack[]>([]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
port.themes().then(setPacks).catch(() => setPacks([]));
|
||||
}, [port]);
|
||||
useEffect(load, [load]);
|
||||
|
||||
// Activating repaints through App's own theme effect, so this only refreshes
|
||||
// the list and asks App to re-read which pack is active.
|
||||
const pick = useCallback(
|
||||
(id: string) => {
|
||||
port
|
||||
.activateTheme(id)
|
||||
.then(() => {
|
||||
load();
|
||||
reloadThemes();
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
[port, load, reloadThemes],
|
||||
);
|
||||
|
||||
const custom = packs.some((p) => p.active);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="grp">
|
||||
<div className="grp-hd">
|
||||
<h2>明暗</h2>
|
||||
</div>
|
||||
<p className="hint">跟随系统时,系统切换会立刻反映;手动选过就固定住。</p>
|
||||
<div className="grp-items">
|
||||
<div className="seg" data-text role="group" aria-label="明暗">
|
||||
{SCHEMES.map(([id, name]) => (
|
||||
<button key={id} aria-pressed={theme === id} onClick={() => onTheme(id)}>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grp">
|
||||
<div className="grp-hd">
|
||||
<h2>配色</h2>
|
||||
<span className="now">{packs.length ? `${packs.length} 个已装` : ""}</span>
|
||||
</div>
|
||||
<p className="hint">
|
||||
装在记忆目录的 themes/ 下,一个目录一个 theme.json。表面、强调色、圆角与字体跟着走;状态色(成功/警告/失败)不跟,那是含义不是装饰。
|
||||
</p>
|
||||
<div className="grp-items">
|
||||
<div className="palettes" role="group" aria-label="配色">
|
||||
<Swatch name="默认" on={!custom} onPick={() => pick("")} />
|
||||
{packs.map((p) => (
|
||||
<Swatch key={p.id} pack={p} theme={theme} name={p.name} on={!!p.active} onPick={() => pick(p.id)} />
|
||||
))}
|
||||
</div>
|
||||
{/* A pack loads with its good tokens and says which ones it lost. The
|
||||
author is the only one who can fix that, and they will not read a
|
||||
log — so it is here, next to the thing that looks wrong. */}
|
||||
{packs.filter((p) => p.warnings?.length).map((p) => (
|
||||
<div className="find" data-lvl="warn" key={p.id}>
|
||||
<span className="t">{p.name} 有几项没生效</span>
|
||||
{p.warnings?.map((w) => (
|
||||
<span className="why" key={w}>
|
||||
{w}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{packs.length === 0 && <p className="note">还没装配色。把一个带 theme.json 的目录放进 themes/ 就会出现在这里。</p>}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// The card paints itself from the pack's own tokens, so a palette is picked by
|
||||
// looking at it rather than by reading its name — and a pack that ships no
|
||||
// picture, or whose picture fails to load, still previews correctly.
|
||||
function Swatch({
|
||||
pack, theme, name, on, onPick,
|
||||
}: {
|
||||
pack?: ThemePack;
|
||||
theme?: string;
|
||||
name: string;
|
||||
on: boolean;
|
||||
onPick: () => void;
|
||||
}) {
|
||||
const [shot, setShot] = useState(true);
|
||||
const tokens = pack && (pack.tokens[activeScheme(theme)] ?? pack.tokens.light ?? pack.tokens.dark);
|
||||
const style = tokens
|
||||
? ({
|
||||
"--pal-page": tokens.bg,
|
||||
"--pal-surface": tokens.bgSoft ?? tokens.bg,
|
||||
"--pal-panel": tokens.panel ?? tokens.bgSoft ?? tokens.bg,
|
||||
"--pal-border": tokens.border,
|
||||
"--pal-text": tokens.fg,
|
||||
"--pal-dim": tokens.fgDim ?? tokens.fg,
|
||||
"--pal-accent": tokens.accent,
|
||||
} as CSSProperties)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<button className="pal" type="button" title={pack?.description || name} data-on={on ? "" : undefined} aria-pressed={on} onClick={onPick}>
|
||||
<span className="pal-art" style={style}>
|
||||
<span className="pal-rail" />
|
||||
<span className="pal-body">
|
||||
<span className="pal-line" />
|
||||
<span className="pal-line" data-short />
|
||||
<span className="pal-mark" />
|
||||
</span>
|
||||
{pack?.hasPreview && shot && (
|
||||
<img
|
||||
className="pal-shot"
|
||||
src={`/themes/${encodeURIComponent(pack.id)}/preview`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={() => setShot(false)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="pal-nm">
|
||||
<b>{name}</b>
|
||||
<em>{pack ? pack.author || "第三方" : "内置"}</em>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// App writes the resolved scheme onto the document, which is the only place
|
||||
// "auto" has an answer.
|
||||
function activeScheme(theme?: string): "light" | "dark" {
|
||||
if (theme === "light" || theme === "dark") return theme;
|
||||
return document.documentElement.dataset.theme === "dark" ? "dark" : "light";
|
||||
}
|
||||
@@ -86,8 +86,21 @@ export function Chrome({ port, status, title, steer, theme, onTheme, onSettings,
|
||||
if (v === "__isolate") return void run(() => port.isolateWorkspace());
|
||||
if (v === "__open") {
|
||||
return void run(async () => {
|
||||
const dir = (await port.pickFolder()) || prompt("工作目录的完整路径") || "";
|
||||
if (dir) await port.setWorkspace(dir);
|
||||
const dir = await port.pickFolder();
|
||||
// "" is the user closing the panel — an answer, not a reason to ask
|
||||
// again through a second dialog.
|
||||
if (dir !== null) {
|
||||
if (dir) await port.setWorkspace(dir);
|
||||
return;
|
||||
}
|
||||
// No native picker. A browser tab types the path instead; the shell is
|
||||
// supposed to have one, and WKWebView answers prompt() with nothing at
|
||||
// all, so there it is a broken build rather than a fallback.
|
||||
if (document.documentElement.dataset.shell === "wails") {
|
||||
throw new Error("这个窗口打不开目录选择器(缺少绑定),请从设置里切换工作区");
|
||||
}
|
||||
const typed = prompt("工作目录的完整路径") || "";
|
||||
if (typed) await port.setWorkspace(typed);
|
||||
});
|
||||
}
|
||||
if (v && v !== root) void run(() => port.setWorkspace(v));
|
||||
|
||||
@@ -3,6 +3,7 @@ import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import remarkGemoji from "remark-gemoji";
|
||||
import { remarkTrimAutolink } from "./autolink";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||
import { useRevealed } from "./reveal";
|
||||
@@ -23,7 +24,7 @@ const schema = {
|
||||
},
|
||||
};
|
||||
|
||||
const REMARK = [remarkGfm, remarkMath, remarkGemoji];
|
||||
const REMARK = [remarkGfm, remarkMath, remarkGemoji, remarkTrimAutolink];
|
||||
// Order is load-bearing: raw parses HTML into nodes, sanitize prunes them, and
|
||||
// katex renders afterwards so its generated markup is not pruned in turn.
|
||||
const BASE_REHYPE = [rehypeRaw, [rehypeSanitize, schema]];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
export interface MenuItem {
|
||||
value: string;
|
||||
@@ -13,6 +13,11 @@ export interface MenuItem {
|
||||
header?: boolean;
|
||||
}
|
||||
|
||||
// Where a list stops fitting the menu's own height cap. Past it a scrollbar
|
||||
// alone still means hunting — one gateway account can publish a hundred
|
||||
// models — and below it the field would be one more thing to look past.
|
||||
const FILTER_FROM = 10;
|
||||
|
||||
interface Props {
|
||||
label: ReactNode;
|
||||
items: MenuItem[];
|
||||
@@ -25,13 +30,26 @@ interface Props {
|
||||
|
||||
export function Picker({ label, items, current, onPick, place, className, title }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const wrap = useRef<HTMLDivElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const btn = useRef<HTMLButtonElement>(null);
|
||||
const find = useRef<HTMLInputElement>(null);
|
||||
|
||||
const choosable = items.filter((it) => !it.header && !it.plain).length;
|
||||
const filtering = choosable > FILTER_FROM;
|
||||
const shown = useMemo(() => (filtering ? matching(items, query) : items), [filtering, items, query]);
|
||||
// What Enter takes while the field still has focus. Only shown once the
|
||||
// query narrows something, or every menu would open pre-selected.
|
||||
const lead = query ? shown.find((it) => !it.header)?.value : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
menu.current?.querySelector<HTMLElement>(".mi")?.focus();
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
return;
|
||||
}
|
||||
if (filtering) find.current?.focus();
|
||||
else menu.current?.querySelector<HTMLElement>("button.mi")?.focus();
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!wrap.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
@@ -49,17 +67,33 @@ export function Picker({ label, items, current, onPick, place, className, title
|
||||
removeEventListener("mousedown", onDown);
|
||||
removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
}, [open]);
|
||||
}, [open, filtering]);
|
||||
|
||||
// Headings are divs and never take focus, so walking the buttons is what
|
||||
// keeps one arrow press from landing on nothing.
|
||||
const arrows = (e: React.KeyboardEvent) => {
|
||||
const all = [...(menu.current?.querySelectorAll<HTMLElement>(".mi") ?? [])];
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
||||
const all = [...(menu.current?.querySelectorAll<HTMLElement>("button.mi") ?? [])];
|
||||
if (!all.length) return;
|
||||
const i = all.indexOf(document.activeElement as HTMLElement);
|
||||
const to = e.key === "ArrowDown" ? i + 1 : e.key === "ArrowUp" ? i - 1 : -1;
|
||||
if (i < 0 || to < 0 || to >= all.length) return;
|
||||
// From the field, Down enters the list at the row Enter would have taken.
|
||||
if (i < 0) {
|
||||
if (e.key !== "ArrowDown") return;
|
||||
e.preventDefault();
|
||||
all[0].focus();
|
||||
return;
|
||||
}
|
||||
const to = e.key === "ArrowDown" ? i + 1 : i - 1;
|
||||
if (to < 0 || to >= all.length) return;
|
||||
e.preventDefault();
|
||||
all[to].focus();
|
||||
};
|
||||
|
||||
const take = (value: string) => {
|
||||
setOpen(false);
|
||||
onPick(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="picker" ref={wrap}>
|
||||
<button
|
||||
@@ -79,35 +113,85 @@ export function Picker({ label, items, current, onPick, place, className, title
|
||||
hidden={!open}
|
||||
onKeyDown={arrows}
|
||||
>
|
||||
{items.map((it) => (
|
||||
{filtering && (
|
||||
<input
|
||||
ref={find}
|
||||
className="mfind"
|
||||
value={query}
|
||||
placeholder={`筛选 ${choosable} 项`}
|
||||
aria-label="筛选"
|
||||
spellCheck={false}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter" || !lead) return;
|
||||
e.preventDefault();
|
||||
take(lead);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{shown.map((it, i) => (
|
||||
<Fragment key={it.value}>
|
||||
{it.divide && <div className="div" />}
|
||||
{it.divide && i > 0 && <div className="div" />}
|
||||
{it.header ? (
|
||||
<div className="mi head">
|
||||
<span className="lb">{it.label}</span>
|
||||
{it.right && <span className="rt">{it.right}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className={it.plain ? "mi plain" : "mi"}
|
||||
role="menuitem"
|
||||
data-on={it.value === current ? "" : undefined}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onPick(it.value);
|
||||
}}
|
||||
>
|
||||
<span className="dot" />
|
||||
<span className="tx">
|
||||
<span className="lb">{it.label}</span>
|
||||
{it.desc && <span className="ds">{it.desc}</span>}
|
||||
</span>
|
||||
{it.right && <span className="rt">{it.right}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={it.plain ? "mi plain" : "mi"}
|
||||
role="menuitem"
|
||||
data-on={it.value === current ? "" : undefined}
|
||||
data-lead={it.value === lead ? "" : undefined}
|
||||
onClick={() => take(it.value)}
|
||||
>
|
||||
<span className="dot" />
|
||||
<span className="tx">
|
||||
<span className="lb">{it.label}</span>
|
||||
{it.desc && <span className="ds">{it.desc}</span>}
|
||||
</span>
|
||||
{it.right && <span className="rt">{it.right}</span>}
|
||||
</button>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{filtering && shown.length === 0 && <div className="mnone">没有匹配的项</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group order is kept rather than ranked by relevance: a list that reorders
|
||||
// itself as you type moves the row out from under the cursor.
|
||||
function matching(items: MenuItem[], query: string): MenuItem[] {
|
||||
const words = query.split(/\s+/).map(normalize).filter(Boolean);
|
||||
if (!words.length) return items;
|
||||
// A row is searchable by the heading above it too, so the account's name
|
||||
// finds models whose own names never mention it.
|
||||
let head = "";
|
||||
const hit = items.map((it) => {
|
||||
if (it.header) {
|
||||
head = normalize(`${it.label} ${it.right ?? ""}`);
|
||||
return false;
|
||||
}
|
||||
// Actions are not data — filtering them out would take "打开其他目录…"
|
||||
// away exactly when the query found nothing and the user needs it.
|
||||
if (it.plain) return false;
|
||||
const hay = `${head}|${normalize(`${it.label} ${it.right ?? ""} ${it.desc ?? ""}`)}`;
|
||||
return words.every((w) => hay.includes(w));
|
||||
});
|
||||
return items.filter((it, i) => {
|
||||
if (it.plain) return true;
|
||||
if (!it.header) return hit[i];
|
||||
for (let j = i + 1; j < items.length && !items[j].header; j++) {
|
||||
if (hit[j]) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// Providers punctuate names inconsistently, so "gpt4" has to find "gpt-4o":
|
||||
// the separators carry nothing worth matching on.
|
||||
function normalize(s: string): string {
|
||||
return s.toLowerCase().replace(/[\s\-_/.]+/g, "");
|
||||
}
|
||||
@@ -22,10 +22,14 @@ interface Props {
|
||||
onFold: () => void;
|
||||
onSettings: () => void;
|
||||
panels: ExtensionSurface[];
|
||||
// Composed views that resolved to the rail — the default place for a standing
|
||||
// surface nobody assigned elsewhere.
|
||||
views?: ExtensionSurface[];
|
||||
onExtInvoke: (name: string) => void;
|
||||
onMoveSurface?: (ext: ExtensionSurface, slot: string) => void;
|
||||
}
|
||||
|
||||
export function Metrics({ metrics, plan, items, jobs, mcp, rate, done, tree, yolo, onFold, onSettings, panels, onExtInvoke }: Props) {
|
||||
export function Metrics({ metrics, plan, items, jobs, mcp, rate, done, tree, yolo, onFold, onSettings, panels, views, onExtInvoke, onMoveSurface }: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="side-hd">
|
||||
@@ -39,7 +43,7 @@ export function Metrics({ metrics, plan, items, jobs, mcp, rate, done, tree, yol
|
||||
<Agents items={items} />
|
||||
<Jobs jobs={jobs} />
|
||||
<Mcp servers={mcp} onOpen={onSettings} />
|
||||
<Extensions panels={panels} onInvoke={onExtInvoke} />
|
||||
<Extensions panels={panels} views={views} onInvoke={onExtInvoke} onMove={onMoveSurface} />
|
||||
<Plan steps={plan} />
|
||||
<Files items={items} yolo={yolo} tree={tree} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useState } from "react";
|
||||
import type { AgentPort, PluginExport, PluginItem, PluginPackage } from "../port/port";
|
||||
import { Switch } from "./Switch";
|
||||
|
||||
interface Props {
|
||||
port: AgentPort;
|
||||
packages: PluginPackage[];
|
||||
onChanged: () => void;
|
||||
// Only one package can be mid-update: the confirmation is a full pane, and
|
||||
// two of them open at once would be two plans competing for one answer.
|
||||
updating: string;
|
||||
onUpdate: (name: string) => void;
|
||||
}
|
||||
|
||||
export function Packages({ port, packages, onChanged, updating, onUpdate }: Props) {
|
||||
return (
|
||||
<>
|
||||
{packages.map((p) => (
|
||||
<Package
|
||||
key={p.name}
|
||||
p={p}
|
||||
port={port}
|
||||
onDone={onChanged}
|
||||
updating={updating === p.name}
|
||||
onUpdate={() => onUpdate(p.name)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// The summary line answers "what did this bring", and the two halves of that
|
||||
// answer are not equal: skills and commands are entries in a menu, while hooks,
|
||||
// servers and a runtime are code that runs. Counting them together would let
|
||||
// the second hide inside the first.
|
||||
function summary(p: PluginPackage): string {
|
||||
const parts: string[] = [];
|
||||
const add = (n: number | undefined, unit: string) => {
|
||||
if (n) parts.push(`${n} ${unit}`);
|
||||
};
|
||||
add(p.skills?.length, "个技能");
|
||||
add(p.commands?.length, "个命令");
|
||||
add(p.agents?.length, "个子代理");
|
||||
add(p.prompts?.length, "个提示词");
|
||||
add(p.themes?.length, "套配色");
|
||||
add(p.hooks?.length, "条钩子");
|
||||
add(p.mcpServers?.length, "个服务");
|
||||
if (p.runtime) parts.push("常驻进程");
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function Package({
|
||||
p, port, onDone, updating, onUpdate,
|
||||
}: {
|
||||
p: PluginPackage; port: AgentPort; onDone: () => void; updating: boolean; onUpdate: () => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState("");
|
||||
const [failed, setFailed] = useState("");
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [exported, setExported] = useState<PluginExport | null>(null);
|
||||
|
||||
const run = async (what: string, fn: () => Promise<unknown>) => {
|
||||
setBusy(what);
|
||||
setFailed("");
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
setFailed(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy("");
|
||||
onDone();
|
||||
}
|
||||
};
|
||||
|
||||
const meta = [p.version, p.manifestKind, p.source].filter(Boolean).join(" · ");
|
||||
const actions = (
|
||||
<span className="acts">
|
||||
{/* Only a package that recorded where it came from can be fetched again;
|
||||
one installed from a folder that has since moved cannot. */}
|
||||
{p.source && (
|
||||
<button className="act ghost" disabled={!!busy || updating} onClick={onUpdate}>
|
||||
更新
|
||||
</button>
|
||||
)}
|
||||
{/* Export and install are one door seen from both sides, so this button
|
||||
says what the archive is for rather than naming a format. */}
|
||||
<button
|
||||
className="act ghost"
|
||||
disabled={!!busy}
|
||||
onClick={() =>
|
||||
void run("export", async () => {
|
||||
setExported(await port.exportPlugin(p.name));
|
||||
})
|
||||
}
|
||||
>
|
||||
{busy === "export" ? "打包中…" : "导出"}
|
||||
</button>
|
||||
<button className="act ghost" aria-label={`移除 ${p.name}`} disabled={!!busy} onClick={() => setConfirming(true)}>
|
||||
移除
|
||||
</button>
|
||||
<Switch
|
||||
on={p.enabled}
|
||||
busy={busy === "toggle"}
|
||||
label={`${p.enabled ? "关闭" : "启用"} ${p.name}`}
|
||||
onClick={() => void run("toggle", () => port.setPluginEnabled(p.name, !p.enabled))}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
const confirm = confirming && (
|
||||
<div className="confirm">
|
||||
<span className="q">
|
||||
删掉 {p.name}?它带来的技能、命令和服务会一起消失。只是想暂时不用的话,关掉开关就够了。
|
||||
</span>
|
||||
<button className="act" onClick={() => setConfirming(false)}>
|
||||
算了
|
||||
</button>
|
||||
<button
|
||||
className="act danger"
|
||||
disabled={busy === "remove"}
|
||||
onClick={() =>
|
||||
void run("remove", async () => {
|
||||
const out = await port.removePlugin(p.name);
|
||||
setConfirming(false);
|
||||
if (!out.ok) setFailed(out.error || out.next || "没能删掉");
|
||||
})
|
||||
}
|
||||
>
|
||||
{busy === "remove" ? "移除中…" : "删掉"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const head = (
|
||||
<>
|
||||
<i className="pip" />
|
||||
<span className="nm">{p.name}</span>
|
||||
<span className="fold">{summary(p) || "没有可用的贡献"}</span>
|
||||
<span className="meta">{meta}</span>
|
||||
{actions}
|
||||
</>
|
||||
);
|
||||
|
||||
const why = p.error || failed;
|
||||
const notes = (
|
||||
<>
|
||||
{why && <div className="why">{why}</div>}
|
||||
{p.status === "disabled_incompatible" && (
|
||||
<div className="why">{p.statusReason || "这个包和当前版本不兼容,已经被停用。"}</div>
|
||||
)}
|
||||
{p.warnings?.map((w) => (
|
||||
<div className="why" key={w}>
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
{exported && (
|
||||
<div className="why">
|
||||
{exported.savedTo ? `存到 ${exported.savedTo}。` : "导出好了。"}
|
||||
{exported.required.length
|
||||
? `里面的密钥值已经去掉,装它的人要自己提供:${exported.required.join("、")}`
|
||||
: "这个包里没有需要对方填的密钥。"}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<details className="srv" data-st={p.enabled ? "ready" : "disabled"} open={confirming || undefined}>
|
||||
<summary>{head}</summary>
|
||||
{confirm}
|
||||
{notes}
|
||||
<div className="peek">
|
||||
{/* What executes comes first: it is the part of a package a user has to
|
||||
know about, and it is also the part they cannot see from a name. */}
|
||||
{p.runtime && (
|
||||
<div className="row" data-run>
|
||||
<span className="d">▸</span>
|
||||
<span>常驻进程</span>
|
||||
<span className="sc">{[p.runtime.command, ...(p.runtime.args ?? [])].join(" ")}</span>
|
||||
</div>
|
||||
)}
|
||||
{p.hooks?.map((h) => (
|
||||
<div className="row" data-run key={h.event + h.command}>
|
||||
<span className="d">▸</span>
|
||||
<span>{h.event}</span>
|
||||
<span className="sc">{h.description || h.command || h.contextFile}</span>
|
||||
</div>
|
||||
))}
|
||||
{p.mcpServers?.map((s) => (
|
||||
<div className="row" data-run key={s.name}>
|
||||
<span className="d">▸</span>
|
||||
<span>{s.displayName || s.name}</span>
|
||||
<span className="sc">{s.description || s.command || s.url || s.transport}</span>
|
||||
</div>
|
||||
))}
|
||||
<Contributions items={p.skills} />
|
||||
<Contributions items={p.commands} />
|
||||
<Contributions items={p.agents} />
|
||||
<Contributions items={p.prompts} />
|
||||
<Contributions items={p.themes} />
|
||||
{p.skipped?.map((s) => (
|
||||
<div className="row" key={s.capability + s.reason}>
|
||||
<span className="d">·</span>
|
||||
<span>{s.capability}</span>
|
||||
<span className="sc">用不了:{s.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function Contributions({ items }: { items?: PluginItem[] }) {
|
||||
if (!items?.length) return null;
|
||||
return (
|
||||
<>
|
||||
{items.map((it) => (
|
||||
<div className="row" key={it.invocation || it.name}>
|
||||
<span className="d">·</span>
|
||||
<span>{it.invocation || it.name}</span>
|
||||
<span className="sc">{it.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { AccountState, AgentPort, ApprovalMode, McpEntry, ModelEntry, Preset, RoleAssignments, SessionStatus, SkillEntry, ThemePack } from "../port/port";
|
||||
import type { AccountState, AgentPort, ApprovalMode, McpEntry, ModelEntry, PluginPackage, Preset, RoleAssignments, SessionStatus, SkillEntry } from "../port/port";
|
||||
import { arrowTabs } from "./tablist";
|
||||
import { WindowControls } from "./WindowControls";
|
||||
import { AddServer } from "./AddServer";
|
||||
import { AddPlugin } from "./AddPlugin";
|
||||
import { Packages } from "./Packages";
|
||||
import { Switch } from "./Switch";
|
||||
import { Hooks } from "./Hooks";
|
||||
import { Network } from "./Network";
|
||||
import { Account } from "./Account";
|
||||
@@ -12,6 +15,7 @@ import { Roles } from "./Roles";
|
||||
import { Boundary } from "./Boundary";
|
||||
import { Versions } from "./Versions";
|
||||
import { Memory } from "./Memory";
|
||||
import { Appearance, SCHEMES } from "./Appearance";
|
||||
|
||||
const PRESETS: [Preset, string, string][] = [
|
||||
["balanced", "均衡", "做到模型认为做完为止。日常用这档"],
|
||||
@@ -25,12 +29,6 @@ const APPROVALS: [ApprovalMode, string, string][] = [
|
||||
["yolo", "全放行", "不问了。只在你完全信任这个工作区时用"],
|
||||
];
|
||||
|
||||
const THEMES: [string, string][] = [
|
||||
["auto", "跟随系统"],
|
||||
["light", "浅色"],
|
||||
["dark", "深色"],
|
||||
];
|
||||
|
||||
type Section = "session" | "model" | "tools" | "hooks" | "ext" | "network" | "memory" | "account" | "versions" | "appearance" | "advanced";
|
||||
|
||||
// What still lives in the old desktop app. Bots are not on the roadmap, so
|
||||
@@ -90,11 +88,13 @@ export function Settings({ port, status, theme, onTheme, onClose, onChanged, rel
|
||||
const [protocol, setProtocol] = useState<Record<string, string>>({});
|
||||
const [mcp, setMcp] = useState<McpEntry[]>([]);
|
||||
const [skills, setSkills] = useState<SkillEntry[]>([]);
|
||||
const [packs, setPacks] = useState<ThemePack[]>([]);
|
||||
const [implicit, setImplicit] = useState(true);
|
||||
const [busy, setBusy] = useState("");
|
||||
const [failed, setFailed] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [packages, setPackages] = useState<PluginPackage[]>([]);
|
||||
const [addingPkg, setAddingPkg] = useState(false);
|
||||
const [updatingPkg, setUpdatingPkg] = useState("");
|
||||
const [hookCount, setHookCount] = useState(0);
|
||||
const [netMode, setNetMode] = useState("");
|
||||
const [memCount, setMemCount] = useState(0);
|
||||
@@ -102,6 +102,7 @@ export function Settings({ port, status, theme, onTheme, onClose, onChanged, rel
|
||||
|
||||
const reloadExt = useCallback(() => {
|
||||
port.mcp().then(setMcp).catch(() => setMcp([]));
|
||||
port.plugins().then(setPackages).catch(() => setPackages([]));
|
||||
port.hooks().then((c) => setHookCount(c.hooks.length)).catch(() => setHookCount(0));
|
||||
port.network().then((n) => setNetMode(NET_MODE[n.mode] ?? n.mode)).catch(() => setNetMode(""));
|
||||
port.memories().then((c) => setMemCount(c.memories.length)).catch(() => setMemCount(0));
|
||||
@@ -121,28 +122,6 @@ export function Settings({ port, status, theme, onTheme, onClose, onChanged, rel
|
||||
onChanged();
|
||||
}, [reloadExt, onChanged]);
|
||||
|
||||
const loadPacks = useCallback(() => {
|
||||
port.themes().then(setPacks).catch(() => setPacks([]));
|
||||
}, [port]);
|
||||
useEffect(loadPacks, [loadPacks]);
|
||||
|
||||
// Activating repaints through App's own theme effect, so this only has to
|
||||
// refresh the list — onChanged is what tells App to re-read it.
|
||||
// Activating repaints through App's theme effect, so this refreshes the list
|
||||
// here and asks App to re-read which pack is active.
|
||||
const pickPack = useCallback(
|
||||
(id: string) => {
|
||||
port
|
||||
.activateTheme(id)
|
||||
.then(() => {
|
||||
loadPacks();
|
||||
reloadThemes();
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
[port, loadPacks, reloadThemes],
|
||||
);
|
||||
|
||||
// Adding or removing a source changes what the picker above can offer, so
|
||||
// the list is reloadable rather than read once at mount.
|
||||
const loadModels = useCallback(() => {
|
||||
@@ -214,7 +193,15 @@ export function Settings({ port, status, theme, onTheme, onClose, onChanged, rel
|
||||
const preset = PRESETS.find(([id]) => id === status?.preset)?.[1] ?? "—";
|
||||
const approval = APPROVALS.find(([id]) => id === status?.toolApprovalMode)?.[1] ?? "—";
|
||||
const broken = mcp.filter((m) => m.state === "failed").length;
|
||||
const skillsOn = skills.filter((s) => s.enabled).length;
|
||||
// A package owns what it brought, and its own row already lists it. What is
|
||||
// left is what the user added by hand, which is the only thing these two
|
||||
// lists can act on without contradicting the package above them. Ownership
|
||||
// is read off the packages themselves: a live server reports the config
|
||||
// layer it was merged from, which is not the same question.
|
||||
const owned = new Set(packages.flatMap((p) => p.mcpServers?.map((s) => s.name) ?? []));
|
||||
const looseMcp = mcp.filter((m) => !owned.has(m.name));
|
||||
const looseSkills = skills.filter((s) => !s.plugin);
|
||||
const looseOn = looseSkills.filter((s) => s.enabled).length;
|
||||
// The table of contents is also the status board: the value that matters for
|
||||
// each section rides on its own row, so the risky one is legible from here.
|
||||
const nav: Record<Section, string> = {
|
||||
@@ -222,12 +209,12 @@ export function Settings({ port, status, theme, onTheme, onClose, onChanged, rel
|
||||
model: status?.modelRef?.split("/").pop() ?? "—",
|
||||
tools: approval,
|
||||
hooks: hookCount ? `${hookCount} 条` : "关",
|
||||
ext: broken ? `${broken} 个异常` : `${mcp.length + skillsOn}`,
|
||||
ext: broken ? `${broken} 个异常` : packages.length ? `${packages.length} 个包` : `${looseMcp.length + looseOn}`,
|
||||
network: netMode,
|
||||
memory: memCount ? `${memCount} 条` : "",
|
||||
account: acct === null ? "" : acct.signedIn ? (acct.user?.label ?? "已登录") : "未登录",
|
||||
versions: "",
|
||||
appearance: THEMES.find(([id]) => id === theme)?.[1] ?? "",
|
||||
appearance: SCHEMES.find(([id]) => id === theme)?.[1] ?? "",
|
||||
advanced: ELSEWHERE.length ? `${ELSEWHERE.length}` : "",
|
||||
};
|
||||
const danger = (id: Section) =>
|
||||
@@ -360,10 +347,45 @@ export function Settings({ port, status, theme, onTheme, onClose, onChanged, rel
|
||||
|
||||
{at === "ext" && (
|
||||
<>
|
||||
<Group
|
||||
title="插件包"
|
||||
now={packages.length ? `${packages.length} 个` : undefined}
|
||||
hint="一个包能一次带来技能、命令、自动化钩子和外部服务。装和导入是同一件事:给它一个仓库地址,或者机器上的一个文件夹。"
|
||||
action={
|
||||
addingPkg ? undefined : (
|
||||
<button className="act" onClick={() => setAddingPkg(true)}>
|
||||
添加
|
||||
</button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{addingPkg && (
|
||||
<AddPlugin port={port} onClose={() => setAddingPkg(false)} onInstalled={afterExtChange} />
|
||||
)}
|
||||
{updatingPkg && (
|
||||
<AddPlugin
|
||||
port={port}
|
||||
updating={packages.find((p) => p.name === updatingPkg)}
|
||||
onClose={() => setUpdatingPkg("")}
|
||||
onInstalled={afterExtChange}
|
||||
/>
|
||||
)}
|
||||
<Packages
|
||||
port={port}
|
||||
packages={packages}
|
||||
onChanged={afterExtChange}
|
||||
updating={updatingPkg}
|
||||
onUpdate={setUpdatingPkg}
|
||||
/>
|
||||
{packages.length === 0 && !addingPkg && <div className="empty">还没装插件包。</div>}
|
||||
</Group>
|
||||
{/* Below the packages: what was added by hand. A server the user
|
||||
typed in themselves is not part of anyone's package, and
|
||||
filing it under one would misname where it came from. */}
|
||||
<Group
|
||||
title="外部工具"
|
||||
now={mcp.length ? `${mcp.length} 个服务` : undefined}
|
||||
hint="通过 MCP 接进来的服务。它给 agent 的能力和内置工具一样真实 —— 列在这里的每一项都能动你的东西。关掉一个会立刻从这一轮的工具表里消失,并且重启后依然是关的。"
|
||||
now={looseMcp.length ? `${looseMcp.length} 个服务` : undefined}
|
||||
hint="你自己接进来的 MCP 服务。它给 agent 的能力和内置工具一样真实 —— 列在这里的每一项都能动你的东西。关掉一个会立刻从这一轮的工具表里消失,并且重启后依然是关的。"
|
||||
action={
|
||||
adding ? undefined : (
|
||||
<button className="act" onClick={() => setAdding(true)}>
|
||||
@@ -380,24 +402,24 @@ export function Settings({ port, status, theme, onTheme, onClose, onChanged, rel
|
||||
onInstalled={afterExtChange}
|
||||
/>
|
||||
)}
|
||||
{mcp.map((m) => (
|
||||
{looseMcp.map((m) => (
|
||||
<Server key={m.name} m={m} port={port} onDone={afterExtChange} />
|
||||
))}
|
||||
{mcp.length === 0 && !adding && <div className="empty">没有接入任何外部服务。</div>}
|
||||
{looseMcp.length === 0 && !adding && <div className="empty">没有自己接入的外部服务。</div>}
|
||||
</Group>
|
||||
<Group
|
||||
title="技能"
|
||||
now={skills.length ? `${skillsOn}/${skills.length} 开着` : undefined}
|
||||
now={looseSkills.length ? `${looseOn}/${looseSkills.length} 开着` : undefined}
|
||||
hint={
|
||||
implicit
|
||||
? "带 / 的可以自己点名调用;其余的由模型按任务自行判断要不要用。关掉的那些两条路都走不通。改动在下一次新建会话时进入模型的索引。"
|
||||
? "工作目录与「我的」里的技能。带 / 的可以自己点名调用;其余的由模型按任务自行判断要不要用。关掉的那些两条路都走不通。改动在下一次新建会话时进入模型的索引。"
|
||||
: "模型自动发现已关闭:现在只有你点名的技能会跑。改动在下一次新建会话时生效。"
|
||||
}
|
||||
>
|
||||
{skills.map((sk) => (
|
||||
{looseSkills.map((sk) => (
|
||||
<SkillRow key={sk.name} sk={sk} implicit={implicit} port={port} onDone={afterExtChange} />
|
||||
))}
|
||||
{skills.length === 0 && <div className="empty">这个工作目录下没有技能。</div>}
|
||||
{looseSkills.length === 0 && <div className="empty">这个工作目录下没有技能。</div>}
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
@@ -438,46 +460,7 @@ export function Settings({ port, status, theme, onTheme, onClose, onChanged, rel
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{at === "appearance" && (
|
||||
<Group title="主题" hint="跟随系统时,系统切换会立刻反映;手动选过就固定住。">
|
||||
{/* 三个没有说明文字的枚举值,跟推理强度是同一种选择,就该长成同一个控件 */}
|
||||
<div className="seg" data-text role="group" aria-label="主题">
|
||||
{THEMES.map(([id, name]) => (
|
||||
<button key={id} aria-pressed={theme === id} onClick={() => onTheme(id)}>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{at === "appearance" && (
|
||||
<Group title="配色" hint="装在记忆目录的 themes/ 下,一个目录一个 theme.json。表面与强调色跟着走,状态色(成功/警告/失败)不跟,那是含义不是装饰。">
|
||||
<div className="lrow">
|
||||
<span className="ds">默认</span>
|
||||
<button className="btn" data-primary={!packs.some((p) => p.active) ? "" : undefined} onClick={() => pickPack("")}>
|
||||
{packs.some((p) => p.active) ? "还原" : "使用中"}
|
||||
</button>
|
||||
</div>
|
||||
{packs.length === 0 && <div className="lrow"><span className="ds">还没装配色</span></div>}
|
||||
{packs.map((p) => (
|
||||
<div className="lrow" key={p.id}>
|
||||
{/* The thumbnail is the pack's own picture, so a palette with
|
||||
a photo behind it does not read the same as one without. */}
|
||||
{p.hasPreview && (
|
||||
<img className="packshot" src={`/themes/${encodeURIComponent(p.id)}/preview`} alt="" loading="lazy" />
|
||||
)}
|
||||
<span className="ds">
|
||||
{p.name}
|
||||
{p.author && <span className="sc">{p.author}</span>}
|
||||
</span>
|
||||
<button className="btn" data-primary={p.active ? "" : undefined} onClick={() => pickPack(p.id)}>
|
||||
{p.active ? "使用中" : "换上"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
{at === "appearance" && <Appearance port={port} theme={theme} onTheme={onTheme} reloadThemes={reloadThemes} />}
|
||||
|
||||
{at === "advanced" && (
|
||||
<Group title="还不在这一版里" hint="每一项都需要自己的界面,做半个不如先说清楚它现在在哪。">
|
||||
@@ -652,18 +635,6 @@ function SkillRow({
|
||||
);
|
||||
}
|
||||
|
||||
function Switch({
|
||||
on, busy, label, onClick,
|
||||
}: {
|
||||
on: boolean; busy?: boolean; label: string; onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button className="sw" role="switch" aria-checked={on} aria-label={label} disabled={busy} onClick={onClick}>
|
||||
<i />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Group({
|
||||
title, hint, now, action, children,
|
||||
}: {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from "react";
|
||||
import type { ExtensionSurface } from "../port/wire";
|
||||
import { ExtensionView } from "./cards/ExtensionView";
|
||||
import { SLOTS, placement } from "./slots";
|
||||
|
||||
// A view plus the one control the host adds to it: where it goes. The handle is
|
||||
// the host's, not the extension's — an extension asks for a place, and this is
|
||||
// where the user overrules it. It stays hidden until the surface is hovered,
|
||||
// because the common case is reading the thing, not moving it.
|
||||
export function SlottedView({
|
||||
ext, assigned = {}, onAction, onMove,
|
||||
}: {
|
||||
ext: ExtensionSurface;
|
||||
assigned?: Record<string, string>;
|
||||
onAction: (actionId: string) => void;
|
||||
onMove: (slot: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const here = placement(ext, assigned);
|
||||
return (
|
||||
<div className="slotted">
|
||||
<ExtensionView body={ext.view?.body ?? []} onAction={onAction} />
|
||||
<button
|
||||
className="movegrip"
|
||||
aria-label={`移动 ${ext.pluginId} 的界面`}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
{open && (
|
||||
<div className="movemenu" role="menu">
|
||||
<span className="who">{ext.pluginId}</span>
|
||||
{SLOTS.map((slot) => (
|
||||
<button
|
||||
key={slot.id}
|
||||
role="menuitemradio"
|
||||
aria-checked={here === slot.id}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onMove(slot.id);
|
||||
}}
|
||||
>
|
||||
{slot.label}
|
||||
</button>
|
||||
))}
|
||||
{/* Clearing hands the choice back to the extension rather than
|
||||
hiding the surface: it is a different act from moving it. */}
|
||||
<button role="menuitem" onClick={() => { setOpen(false); onMove(""); }}>
|
||||
交还给插件
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// The one on/off control in the settings pane: a durable switch, not a
|
||||
// command. Everything that can be turned back on by clicking again is one.
|
||||
export function Switch({
|
||||
on, busy, label, onClick,
|
||||
}: {
|
||||
on: boolean; busy?: boolean; label: string; onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button className="sw" role="switch" aria-checked={on} aria-label={label} disabled={busy} onClick={onClick}>
|
||||
<i />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// GFM ends a bare URL at ASCII punctuation, and nothing else. A Chinese
|
||||
// sentence closes with full-width marks — "…127.0.0.1:8787(HTTP 200,正常)" —
|
||||
// so the link swallowed the rest of the sentence up to the next space, and the
|
||||
// underline ran on past the port. None of these ever appear inside a URL; CJK
|
||||
// ideographs are deliberately not in the set, so an IDN host still autolinks.
|
||||
const CJK_PUNCT = /[‘-‟…— -〿!-・]/u;
|
||||
|
||||
interface Node {
|
||||
type: string;
|
||||
url?: string;
|
||||
value?: string;
|
||||
children?: Node[];
|
||||
}
|
||||
|
||||
// remarkTrimAutolink cuts a literal autolink at the first character no URL can
|
||||
// contain and hands the remainder back to the paragraph as text. An explicit
|
||||
// [label](url) is left alone: there the author chose both halves.
|
||||
export function remarkTrimAutolink() {
|
||||
return (tree: Node) => walk(tree);
|
||||
}
|
||||
|
||||
function walk(node: Node) {
|
||||
const kids = node.children;
|
||||
if (!kids) return;
|
||||
for (let i = 0; i < kids.length; i++) {
|
||||
const n = kids[i];
|
||||
const cut = n.type === "link" && n.url ? literalCut(n) : -1;
|
||||
if (cut > 0 && n.url) {
|
||||
const rest = n.url.slice(cut);
|
||||
n.url = n.url.slice(0, cut);
|
||||
n.children = [{ type: "text", value: n.url }];
|
||||
kids.splice(i + 1, 0, { type: "text", value: rest });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
walk(n);
|
||||
}
|
||||
}
|
||||
|
||||
// A literal autolink renders its own URL as its only child; "www.x.com" also
|
||||
// counts, where gfm prepends the scheme it left out.
|
||||
function literalCut(link: Node): number {
|
||||
const text = link.children?.length === 1 && link.children[0].type === "text" ? link.children[0].value : undefined;
|
||||
if (!text || !link.url || (link.url !== text && !link.url.endsWith(text))) return -1;
|
||||
return link.url.search(CJK_PUNCT);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { ExtensionView } from "./ExtensionView";
|
||||
import type { ExtensionSurface, ExtensionFormField } from "../../port/wire";
|
||||
import { Markdown } from "../Markdown";
|
||||
import { Sym } from "../Sym";
|
||||
@@ -60,6 +61,11 @@ export function ExtensionCard({ ext, onInvoke, onSubmit }: Props) {
|
||||
{ext.form?.title && <span className="arg">{ext.form.title}</span>}
|
||||
</div>
|
||||
<div className="out">
|
||||
{/* A composed surface draws itself from primitives. It comes first
|
||||
because an extension that sent one meant it as the surface, not
|
||||
as a decoration on the fixed fields. */}
|
||||
{ext.view && <ExtensionView body={ext.view.body} onAction={onInvoke} />}
|
||||
|
||||
{ext.status && (
|
||||
<div className="finds">
|
||||
<div className="find" data-lvl={lvl(ext.status.severity)}>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { ExtensionViewNode } from "../../port/wire";
|
||||
|
||||
// The host half of the composed-surface contract: an extension sends a tree of
|
||||
// primitives, and every one of them is drawn here with the components this app
|
||||
// already uses. Nothing in a node names a size, a position or a colour, so a
|
||||
// view cannot drift away from the rest of the window — and the same tree can be
|
||||
// rendered as text by a terminal frontend that has none of these elements.
|
||||
|
||||
interface Props {
|
||||
body: ExtensionViewNode[];
|
||||
onAction?: (actionId: string) => void;
|
||||
}
|
||||
|
||||
export function ExtensionView({ body, onAction }: Props) {
|
||||
return (
|
||||
<div className="xview">
|
||||
<Nodes nodes={body} onAction={onAction} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Nodes({ nodes, onAction }: { nodes: ExtensionViewNode[]; onAction?: (id: string) => void }) {
|
||||
return (
|
||||
<>
|
||||
{nodes.map((n, i) => (
|
||||
<Node key={`${n.kind}:${n.key ?? n.actionId ?? n.value ?? i}`} n={n} onAction={onAction} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Node({ n, onAction }: { n: ExtensionViewNode; onAction?: (id: string) => void }) {
|
||||
switch (n.kind) {
|
||||
case "text":
|
||||
return <span className="xv-t" data-tone={n.tone}>{n.value}</span>;
|
||||
// Markdown is the one primitive that carries a document rather than a
|
||||
// value. It is deliberately rendered as plain text here: the transcript's
|
||||
// renderer assumes trusted content, and a view is not that.
|
||||
case "markdown":
|
||||
return <p className="xv-md">{n.value}</p>;
|
||||
case "row":
|
||||
return (
|
||||
<div className="xv-row">
|
||||
<Nodes nodes={n.children ?? []} onAction={onAction} />
|
||||
</div>
|
||||
);
|
||||
case "stack":
|
||||
return (
|
||||
<div className="xv-stack">
|
||||
<Nodes nodes={n.children ?? []} onAction={onAction} />
|
||||
</div>
|
||||
);
|
||||
case "kv":
|
||||
return (
|
||||
<div className="xv-kv">
|
||||
<span className="k">{n.key}</span>
|
||||
<span className="v">{n.value}</span>
|
||||
</div>
|
||||
);
|
||||
case "meter":
|
||||
return (
|
||||
<div className="xv-meter" role="progressbar" aria-valuenow={Math.round((n.progress ?? 0) * 100)}>
|
||||
<i style={{ width: `${Math.round(Math.max(0, Math.min(1, n.progress ?? 0)) * 100)}%` }} />
|
||||
{n.label && <span className="lb">{n.label}</span>}
|
||||
</div>
|
||||
);
|
||||
case "pip":
|
||||
return <i className="pip" data-tone={n.tone} />;
|
||||
case "button":
|
||||
return (
|
||||
<button className="act" onClick={() => n.actionId && onAction?.(n.actionId)}>
|
||||
{n.label}
|
||||
</button>
|
||||
);
|
||||
case "divider":
|
||||
return <hr className="xv-hr" />;
|
||||
default:
|
||||
// A node kind this build does not know is skipped rather than shown as
|
||||
// a blank: an older frontend meeting a newer extension should look like
|
||||
// it is missing a feature, not like it is broken.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,38 @@
|
||||
import type { ExtensionSurface } from "../../port/wire";
|
||||
import { SlottedView } from "../SlottedView";
|
||||
|
||||
// Standing surfaces an extension published. They live here rather than in the
|
||||
// transcript because they describe a state that is still true — a watcher, a
|
||||
// sync, a connection — which scrolling away would hide.
|
||||
export function Extensions({
|
||||
panels,
|
||||
views = [],
|
||||
onInvoke,
|
||||
onMove,
|
||||
}: {
|
||||
panels: ExtensionSurface[];
|
||||
// Composed views that landed here: either the user put them here, or nobody
|
||||
// named a place they could be put. The rail is where a standing surface goes
|
||||
// when no one said otherwise.
|
||||
views?: ExtensionSurface[];
|
||||
onInvoke: (name: string) => void;
|
||||
onMove?: (ext: ExtensionSurface, slot: string) => void;
|
||||
}) {
|
||||
if (panels.length === 0) return null;
|
||||
if (panels.length === 0 && views.length === 0) return null;
|
||||
return (
|
||||
<div className="block" data-b="extpanels">
|
||||
<div className="lbl">
|
||||
扩展<span className="c">{panels.length}</span>
|
||||
扩展<span className="c">{panels.length + views.length}</span>
|
||||
</div>
|
||||
{views.map((v) => (
|
||||
<div className="extpanel" key={`${v.pluginId}:${v.surfaceId}`}>
|
||||
<SlottedView
|
||||
ext={v}
|
||||
onAction={onInvoke}
|
||||
onMove={(slot) => onMove?.(v, slot)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{panels.map((p) => {
|
||||
const panel = p.panel;
|
||||
if (!panel) return null;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ExtensionSurface } from "../port/wire";
|
||||
|
||||
// The places this frontend offers. The list is ours alone: a terminal frontend
|
||||
// has different ones and neither has to tell the protocol, so adding a place
|
||||
// here is a frontend change rather than a protocol version. An extension names
|
||||
// a slot as a request; a name we do not have falls back to the rail, which is
|
||||
// what lets the same extension show up in a window and in a terminal.
|
||||
export const SLOTS: { id: string; label: string }[] = [
|
||||
{ id: "sidebar", label: "侧栏" },
|
||||
{ id: "composer-trailing", label: "输入框上方" },
|
||||
];
|
||||
|
||||
export const DEFAULT_SLOT = "sidebar";
|
||||
|
||||
/** key is how a placement is addressed: one extension's two surfaces can sit
|
||||
* in different places, so the surface is the unit, not the plugin. */
|
||||
export function key(ext: ExtensionSurface): string {
|
||||
return `${ext.pluginId}:${ext.surfaceId}`;
|
||||
}
|
||||
|
||||
/** placement resolves the three answers in order — what the user said, what the
|
||||
* extension asked for, and where this frontend puts things it was told nothing
|
||||
* about. A name none of us knows resolves to the default rather than nowhere. */
|
||||
export function placement(ext: ExtensionSurface, assigned: Record<string, string>): string {
|
||||
const candidate = assigned[key(ext)] || ext.view?.slot || DEFAULT_SLOT;
|
||||
return SLOTS.some((s) => s.id === candidate) ? candidate : DEFAULT_SLOT;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ThemePack } from "../port/port";
|
||||
|
||||
// A pack names colours in its own vocabulary; this is where they become ours.
|
||||
// A pack names things in its own vocabulary; this is where they become ours.
|
||||
// The mapping lives on this side because only the frontend knows what each
|
||||
// surface in its layout is called — a pack should not have to learn our
|
||||
// variable names to be worth installing.
|
||||
// variable names to be worth installing. The vocabulary itself is the kernel's
|
||||
// (internal/theme.Tokens), and TestThemeTokenVocabularyMatchesTheFrontend holds
|
||||
// the two halves together.
|
||||
const SURFACE: Record<string, string[]> = {
|
||||
bg: ["--page"],
|
||||
bgSoft: ["--surface"],
|
||||
@@ -16,6 +18,13 @@ const SURFACE: Record<string, string[]> = {
|
||||
fgFaint: ["--faint", "--ghost"],
|
||||
accent: ["--accent"],
|
||||
accentFg: ["--accent-fg"],
|
||||
// Shape and type. --r-pill is absent because a pill is a shape rather than a
|
||||
// size: a pack that could set it would round a button into something else.
|
||||
radiusXs: ["--r-xs"],
|
||||
radiusSm: ["--r-sm"],
|
||||
radiusMd: ["--r-md"],
|
||||
fontUi: ["--ui"],
|
||||
fontMono: ["--mono"],
|
||||
};
|
||||
|
||||
// What a pack may not touch. ok/warn/err/net/deleg encode what is happening —
|
||||
|
||||
@@ -15,7 +15,7 @@ const ROUTES = [
|
||||
"/inbox", "/trajectory", "/mcp", "/skills", "/complete", "/workspace",
|
||||
"/providers", "/roles", "/account", "/hooks", "/memory", "/network", "/todos",
|
||||
"/changes", "/attachments", "/checkpoints", "/branches", "/compact", "/rewind",
|
||||
"/extensions", "/themes",
|
||||
"/extensions", "/themes", "/plugins", "/surfaces",
|
||||
"/fork", "/summarize", "/forget", "/bypass", "/auto-approve-tools",
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// filedrop.go — dropped files, as paths rather than bytes.
|
||||
package main
|
||||
|
||||
import "github.com/wailsapp/wails/v2/pkg/options"
|
||||
|
||||
// dragAndDrop asks the native side to report dropped files' absolute paths: a
|
||||
// webview hands JavaScript a File and never a path, which is the one thing an
|
||||
// installer needs. Nothing subscribes here — the page takes them through the
|
||||
// Wails runtime, the only subscription that filters by drop target. The two
|
||||
// tests next door say what each half of that is load-bearing for.
|
||||
func dragAndDrop() *options.DragAndDrop {
|
||||
return &options.DragAndDrop{EnableFileDrop: true}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Asking for dropped paths must not cost the window its ordinary drop. The
|
||||
// composer receives pasted and dropped images through the DOM event, and
|
||||
// DisableWebViewDrop is the one switch that would silence it — on macOS by
|
||||
// short-circuiting performDragOperation, on Linux by gtk_drag_dest_unset.
|
||||
// Nothing else in this repository would notice until an image stopped landing.
|
||||
func TestFileDropKeepsTheWebviewsOwnDrop(t *testing.T) {
|
||||
dnd := dragAndDrop()
|
||||
if dnd == nil || !dnd.EnableFileDrop {
|
||||
t.Fatal("file drop is off; an installer cannot resolve a dropped folder without it")
|
||||
}
|
||||
if dnd.DisableWebViewDrop {
|
||||
t.Fatal("DisableWebViewDrop would take dropped images away from the composer")
|
||||
}
|
||||
}
|
||||
|
||||
// The paths are taken through the Wails runtime in the page, not relayed from
|
||||
// here: only that subscription filters by drop target, and a relay would report
|
||||
// every drop in the window — including an image meant for the composer.
|
||||
func TestPathsAreNotRelayedFromTheShell(t *testing.T) {
|
||||
src, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(src), "OnFileDrop") {
|
||||
t.Fatal("the shell subscribes to file drops; that channel is unfiltered, so the page must be the subscriber")
|
||||
}
|
||||
}
|
||||
|
||||
// A path only reaches the callback when the drop lands on an element that opted
|
||||
// in, so the opt-in has to exist somewhere. Without it the feature is wired end
|
||||
// to end and silently never fires.
|
||||
func TestSomethingOptsInAsADropTarget(t *testing.T) {
|
||||
css, err := os.ReadFile(filepath.Join("..", "frontend-next", "src", "styles", "app.css"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(css), "--wails-drop-target: drop") {
|
||||
t.Fatal("no element declares --wails-drop-target: drop, so no drop is ever reported")
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ var apiPaths = map[string]bool{
|
||||
"/mcp": true, "/skills": true, "/account": true, "/hooks": true,
|
||||
"/memory": true, "/network": true, "/todos": true, "/providers": true,
|
||||
"/changes": true, "/attachments": true, "/roles": true,
|
||||
"/themes": true, "/extensions": true, "/plugins": true,
|
||||
"/themes": true, "/extensions": true, "/plugins": true, "/surfaces": true,
|
||||
}
|
||||
|
||||
// A sub-path belongs to the resource it hangs off: /mcp/reconnect is the same
|
||||
@@ -162,6 +162,7 @@ func run() error {
|
||||
// or select-all on right-click — in a text editor that reads as broken.
|
||||
EnableDefaultContextMenu: true,
|
||||
Bind: []any{shell},
|
||||
DragAndDrop: dragAndDrop(),
|
||||
OnStartup: func(ctx context.Context) {
|
||||
shell.ctx = ctx
|
||||
applyDockIcon()
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// plugin_export.go — handing a packed plugin package to the user.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/pluginpkg"
|
||||
)
|
||||
|
||||
// PluginExport is what the window answers with: where the archive landed, and
|
||||
// the variables its configuration no longer carries. An empty path is the
|
||||
// user cancelling, which is not a failure.
|
||||
type PluginExport struct {
|
||||
Path string `json:"path"`
|
||||
Required []string `json:"required"`
|
||||
}
|
||||
|
||||
// SavePluginExport packs an installed package and writes it wherever the native
|
||||
// dialog points. A WKWebView starts no downloads of its own, so the browser
|
||||
// build's <a download> is not a path this window has — without this binding the
|
||||
// export button would do nothing at all on macOS.
|
||||
func (a *App) SavePluginExport(name string) (PluginExport, error) {
|
||||
if a.ctx == nil {
|
||||
return PluginExport{}, nil
|
||||
}
|
||||
home := config.ReasonixHomeDir()
|
||||
st, err := pluginpkg.LoadState(home)
|
||||
if err != nil {
|
||||
return PluginExport{}, err
|
||||
}
|
||||
root := ""
|
||||
for _, p := range st.Plugins {
|
||||
if p.Name == name {
|
||||
root = pluginpkg.ResolveRoot(home, p.Root)
|
||||
break
|
||||
}
|
||||
}
|
||||
if root == "" {
|
||||
return PluginExport{}, fmt.Errorf("plugin %q is not installed", name)
|
||||
}
|
||||
archive, required, err := pluginpkg.Export(name, root)
|
||||
if err != nil {
|
||||
return PluginExport{}, err
|
||||
}
|
||||
// Packed before the dialog opens: a package that cannot be packed should
|
||||
// say so instead of asking where to put a file it will never write.
|
||||
path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||
Title: "导出插件包",
|
||||
DefaultFilename: name + ".zip",
|
||||
})
|
||||
if err != nil {
|
||||
return PluginExport{}, err
|
||||
}
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return PluginExport{Required: required}, nil
|
||||
}
|
||||
if err := os.WriteFile(path, archive, 0o644); err != nil {
|
||||
return PluginExport{}, err
|
||||
}
|
||||
return PluginExport{Path: path, Required: required}, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/theme"
|
||||
)
|
||||
|
||||
// The vocabulary a theme pack writes in is the kernel's (internal/theme.Tokens
|
||||
// decides what is a token and what its value may look like), while the mapping
|
||||
// onto CSS variables is the frontend's. Neither half can see the other, so a
|
||||
// token added on one side alone is accepted and then silently never painted —
|
||||
// or painted and then rejected. This is where the two are held together.
|
||||
func TestThemeTokenVocabularyMatchesTheFrontend(t *testing.T) {
|
||||
src, err := os.ReadFile(filepath.Join("..", "frontend-next", "src", "ui", "theme.ts"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
block, ok := surfaceBlock(string(src))
|
||||
if !ok {
|
||||
t.Fatal("theme.ts no longer declares a SURFACE map; this test cannot see the mapping")
|
||||
}
|
||||
mapped := map[string]bool{}
|
||||
for _, m := range regexp.MustCompile(`(?m)^\s+(\w+):\s*\[`).FindAllStringSubmatch(block, -1) {
|
||||
mapped[m[1]] = true
|
||||
}
|
||||
for _, name := range theme.TokenNames() {
|
||||
if !mapped[name] {
|
||||
t.Errorf("kernel accepts token %q but theme.ts maps it to nothing, so a pack setting it changes nothing", name)
|
||||
}
|
||||
delete(mapped, name)
|
||||
}
|
||||
for name := range mapped {
|
||||
t.Errorf("theme.ts maps %q but the kernel drops it as unknown, so a pack setting it is warned about and ignored", name)
|
||||
}
|
||||
}
|
||||
|
||||
func surfaceBlock(src string) (string, bool) {
|
||||
start := strings.Index(src, "const SURFACE")
|
||||
if start < 0 {
|
||||
return "", false
|
||||
}
|
||||
end := strings.Index(src[start:], "\n};")
|
||||
if end < 0 {
|
||||
return "", false
|
||||
}
|
||||
return src[start : start+end], true
|
||||
}
|
||||
|
||||
// The meanings a pack cannot take. A theme that could recolour these would let
|
||||
// a failure render as success, so the list is pinned on both sides: the kernel
|
||||
// refuses them by omission from its vocabulary, the frontend by never mapping
|
||||
// them, and this says the frontend still remembers why.
|
||||
func TestReservedMeaningsAreNotThemeTokens(t *testing.T) {
|
||||
for _, reserved := range []string{"ok", "warn", "err", "net", "deleg", "add", "del", "focus"} {
|
||||
if _, taken := theme.Tokens[reserved]; taken {
|
||||
t.Errorf("%q became a theme token; a pack can now recolour a meaning", reserved)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
- Protocol ID: `reasonix.extension.v2`
|
||||
- Protocol major: `2`
|
||||
- Schema: `internal/extension/protocol/schema.generated.json`
|
||||
- Schema hash: `sha256:74469060337755e305098bc21d75b0255028e12043f411a4408f2344be83548f`
|
||||
- Schema hash: `sha256:c19003186419665354115b5a71e525423303edf788b0738ebcbfd6427732001b`
|
||||
|
||||
Within major v2 only optional fields, new enum values, and new methods may
|
||||
be added; existing required fields, directions, limits, error reasons, and
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
// The two-phase rule only binds the model if the model is told about it, and
|
||||
// the tool contract assembled here is the only place it is told. Refusing an
|
||||
// unticketed apply is covered where it happens (installsource); what this pins
|
||||
// is that the sentence describing it survives into the assembled runtime,
|
||||
// because a tool description is edited far more often than a refusal path.
|
||||
func TestInstallSourceContractStatesTheTicketRule(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
|
||||
ctrl, err := Build(context.Background(), Options{
|
||||
SessionDir: filepath.Join(t.TempDir(), "sessions"),
|
||||
TokenMode: TokenModeFull,
|
||||
Sink: event.Discard,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
|
||||
for _, entry := range ctrl.AllToolContractEntries() {
|
||||
if entry.Name != "install_source" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(entry.Description, "planId") {
|
||||
t.Fatalf("install_source description never names the ticket an apply must carry:\n%s", entry.Description)
|
||||
}
|
||||
if !strings.Contains(string(entry.Schema), "Required with apply=true") {
|
||||
t.Fatalf("planId schema does not say it is required to apply:\n%s", entry.Schema)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("install_source is not in the assembled tool contract")
|
||||
}
|
||||
@@ -5,26 +5,29 @@ package config
|
||||
// separate from top-level language and [ui] so desktop choices do not affect CLI
|
||||
// language, terminal colours, or provider-visible prompt/request data.
|
||||
type DesktopConfig struct {
|
||||
Language string `toml:"language"` // auto|en|zh; empty/auto = browser/OS auto-detect
|
||||
Currency string `toml:"currency"` // legacy display currency; migrated to [billing].display_currency
|
||||
LayoutStyle string `toml:"layout_style"` // classic|workbench|creation; desktop layout style
|
||||
Theme string `toml:"theme"` // auto|dark|light; empty resolves to auto
|
||||
ThemeStyle string `toml:"theme_style"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases
|
||||
ThemePack string `toml:"theme_pack"` // installed pack id; empty is the default appearance
|
||||
TerminalTheme string `toml:"terminal_theme"` // auto|dark|light; auto follows the desktop app theme
|
||||
ExternalOpener string `toml:"external_opener"` // preferred installed app used by the desktop Open control
|
||||
CloseBehavior string `toml:"close_behavior"` // quit|background; desktop window close behavior
|
||||
DisplayMode string `toml:"display_mode"` // standard|compact (legacy "minimal" maps to compact); transcript display mode
|
||||
StatusBarStyle string `toml:"status_bar_style"` // icon|text; desktop status bar metric labels
|
||||
StatusBarItems []string `toml:"status_bar_items"` // ordered visible desktop status bar items
|
||||
DefaultToolApprovalMode string `toml:"default_tool_approval_mode"` // ask|auto|yolo; defaults to auto for newly-created desktop sessions
|
||||
CheckUpdates *bool `toml:"check_updates"` // startup update checks; nil keeps the default enabled
|
||||
UpdateChannel string `toml:"update_channel"` // legacy: read for compatibility, never written back
|
||||
Telemetry *bool `toml:"telemetry"` // anonymous launch ping plus scrubbed next-launch native crash diagnostics; nil keeps the default enabled
|
||||
Metrics *bool `toml:"metrics"` // aggregate desktop metrics (anonymous signal/bucket counts, including lifecycle health; no content); nil keeps the default enabled
|
||||
ProviderAccess []string `toml:"provider_access"` // desktop-only list of provider entries shown in Settings > Model > Access
|
||||
ExpandThinking bool `toml:"expand_thinking"` // deprecated compatibility alias: true maps to auto
|
||||
ReasoningDisplayMode string `toml:"reasoning_display_mode"`
|
||||
ConversationWidth string `toml:"conversation_width"` // standard|full; max transcript width; empty = standard
|
||||
PinnedVersion string `toml:"pinned_version"` // release pinned by a rollback; empty follows the channel
|
||||
Language string `toml:"language"` // auto|en|zh; empty/auto = browser/OS auto-detect
|
||||
Currency string `toml:"currency"` // legacy display currency; migrated to [billing].display_currency
|
||||
LayoutStyle string `toml:"layout_style"` // classic|workbench|creation; desktop layout style
|
||||
Theme string `toml:"theme"` // auto|dark|light; empty resolves to auto
|
||||
ThemeStyle string `toml:"theme_style"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases
|
||||
ThemePack string `toml:"theme_pack"` // installed pack id; empty is the default appearance
|
||||
// SurfaceSlots is where the user put an extension's surface, keyed
|
||||
// "<pluginId>:<surfaceId>". It outranks the extension's own suggestion.
|
||||
SurfaceSlots map[string]string `toml:"surface_slots"`
|
||||
TerminalTheme string `toml:"terminal_theme"` // auto|dark|light; auto follows the desktop app theme
|
||||
ExternalOpener string `toml:"external_opener"` // preferred installed app used by the desktop Open control
|
||||
CloseBehavior string `toml:"close_behavior"` // quit|background; desktop window close behavior
|
||||
DisplayMode string `toml:"display_mode"` // standard|compact (legacy "minimal" maps to compact); transcript display mode
|
||||
StatusBarStyle string `toml:"status_bar_style"` // icon|text; desktop status bar metric labels
|
||||
StatusBarItems []string `toml:"status_bar_items"` // ordered visible desktop status bar items
|
||||
DefaultToolApprovalMode string `toml:"default_tool_approval_mode"` // ask|auto|yolo; defaults to auto for newly-created desktop sessions
|
||||
CheckUpdates *bool `toml:"check_updates"` // startup update checks; nil keeps the default enabled
|
||||
UpdateChannel string `toml:"update_channel"` // legacy: read for compatibility, never written back
|
||||
Telemetry *bool `toml:"telemetry"` // anonymous launch ping plus scrubbed next-launch native crash diagnostics; nil keeps the default enabled
|
||||
Metrics *bool `toml:"metrics"` // aggregate desktop metrics (anonymous signal/bucket counts, including lifecycle health; no content); nil keeps the default enabled
|
||||
ProviderAccess []string `toml:"provider_access"` // desktop-only list of provider entries shown in Settings > Model > Access
|
||||
ExpandThinking bool `toml:"expand_thinking"` // deprecated compatibility alias: true maps to auto
|
||||
ReasoningDisplayMode string `toml:"reasoning_display_mode"`
|
||||
ConversationWidth string `toml:"conversation_width"` // standard|full; max transcript width; empty = standard
|
||||
PinnedVersion string `toml:"pinned_version"` // release pinned by a rollback; empty follows the channel
|
||||
}
|
||||
@@ -29,6 +29,9 @@ func renderDesktopSection(b *strings.Builder, c *Config) {
|
||||
} else {
|
||||
b.WriteString("# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n")
|
||||
}
|
||||
if len(c.Desktop.SurfaceSlots) > 0 {
|
||||
fmt.Fprintf(b, "surface_slots = %s # where the user put each extension surface; overrides what the extension asked for\n", renderStringMap(c.Desktop.SurfaceSlots))
|
||||
}
|
||||
if pack := strings.TrimSpace(c.Desktop.ThemePack); pack != "" {
|
||||
fmt.Fprintf(b, "theme_pack = %q # installed theme pack id; empty is the default appearance\n", pack)
|
||||
} else {
|
||||
|
||||
@@ -14,6 +14,7 @@ const (
|
||||
ExtensionSurfaceForm = "form"
|
||||
ExtensionSurfaceNotification = "notification"
|
||||
ExtensionSurfaceRequest = "request"
|
||||
ExtensionSurfaceView = "view"
|
||||
)
|
||||
|
||||
// ExtensionSurfacePayload carries one sidecar's UI contribution for the
|
||||
@@ -24,12 +25,35 @@ type ExtensionSurfacePayload struct {
|
||||
SurfaceID string
|
||||
SessionID string
|
||||
Generation uint64
|
||||
Kind string // status | card | form | notification | panel
|
||||
Kind string // status | card | form | notification | panel | view
|
||||
Status *ExtensionStatusView
|
||||
Card *ExtensionCardView
|
||||
Form *ExtensionFormView
|
||||
Notification *ExtensionNotificationView
|
||||
Panel *ExtensionPanelView
|
||||
View *ExtensionViewSurface
|
||||
}
|
||||
|
||||
// ExtensionViewSurface is a surface composed from host primitives rather than
|
||||
// filled into a fixed shape (mirrors UIViewPayload). Slot is a name the
|
||||
// frontend may not know; an unrecognised one renders wherever that frontend
|
||||
// puts views it has no place for.
|
||||
type ExtensionViewSurface struct {
|
||||
Slot string
|
||||
Body []ExtensionViewNode
|
||||
}
|
||||
|
||||
// ExtensionViewNode mirrors protocol.UINode. Tone says what a node means, not
|
||||
// what colour it is: the palette stays the frontend's.
|
||||
type ExtensionViewNode struct {
|
||||
Kind string
|
||||
Value string
|
||||
Key string
|
||||
Label string
|
||||
Tone string
|
||||
Progress *float64
|
||||
ActionID string
|
||||
Children []ExtensionViewNode
|
||||
}
|
||||
|
||||
// ExtensionPanelView is a standing surface for the frontend's side rail
|
||||
|
||||
@@ -21,6 +21,26 @@ type ExtensionSurface struct {
|
||||
Form *ExtensionForm `json:"form,omitempty"`
|
||||
Notification *ExtensionNotification `json:"notification,omitempty"`
|
||||
Panel *ExtensionPanel `json:"panel,omitempty"`
|
||||
View *ExtensionView `json:"view,omitempty"`
|
||||
}
|
||||
|
||||
// ExtensionView is the JSON form of an event.ExtensionViewSurface: a tree the
|
||||
// frontend renders with its own components.
|
||||
type ExtensionView struct {
|
||||
Slot string `json:"slot,omitempty"`
|
||||
Body []ExtensionViewNode `json:"body"`
|
||||
}
|
||||
|
||||
// ExtensionViewNode is one primitive of a view.
|
||||
type ExtensionViewNode struct {
|
||||
Kind string `json:"kind"`
|
||||
Value string `json:"value,omitempty" externalizable:"true"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Tone string `json:"tone,omitempty"`
|
||||
Progress *float64 `json:"progress,omitempty"`
|
||||
ActionID string `json:"actionId,omitempty"`
|
||||
Children []ExtensionViewNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// ExtensionPanel is the JSON form of an event.ExtensionPanelView.
|
||||
@@ -156,5 +176,23 @@ func ToWireExtensionSurface(p *event.ExtensionSurfacePayload) *ExtensionSurface
|
||||
}
|
||||
out.Panel = panel
|
||||
}
|
||||
if v := p.View; v != nil {
|
||||
out.View = &ExtensionView{Slot: v.Slot, Body: viewNodes(v.Body)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func viewNodes(in []event.ExtensionViewNode) []ExtensionViewNode {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]ExtensionViewNode, 0, len(in))
|
||||
for _, n := range in {
|
||||
out = append(out, ExtensionViewNode{
|
||||
Kind: n.Kind, Value: n.Value, Key: n.Key, Label: n.Label,
|
||||
Tone: n.Tone, Progress: n.Progress, ActionID: n.ActionID,
|
||||
Children: viewNodes(n.Children),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -101,3 +101,40 @@ func TestToWireExtensionNilPayload(t *testing.T) {
|
||||
t.Fatalf("nil payload JSON = %s, must omit the extension field", b)
|
||||
}
|
||||
}
|
||||
|
||||
// The JSON a view serializes to is what the frontend's ExtensionViewNode type
|
||||
// is written against, and nothing checks the two against each other at build
|
||||
// time. Pinning the wire shape here is what makes a renamed field a failing
|
||||
// test rather than a node that silently stops rendering.
|
||||
func TestToWireExtensionViewShape(t *testing.T) {
|
||||
progress := 0.62
|
||||
ev := event.Event{Kind: event.ExtensionSurface, Extension: &event.ExtensionSurfacePayload{
|
||||
PluginID: "alpha", SurfaceID: "usage", Kind: event.ExtensionSurfaceView,
|
||||
View: &event.ExtensionViewSurface{
|
||||
Slot: "composer-trailing",
|
||||
Body: []event.ExtensionViewNode{
|
||||
{Kind: "row", Children: []event.ExtensionViewNode{
|
||||
{Kind: "pip", Tone: "ok"},
|
||||
{Kind: "text", Value: "quota 62%", Tone: "strong"},
|
||||
}},
|
||||
{Kind: "meter", Progress: &progress},
|
||||
{Kind: "button", ActionID: "open-billing", Label: "manage"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
raw, err := json.Marshal(ToWire(ev))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`"kind":"view"`,
|
||||
`"view":{"slot":"composer-trailing"`,
|
||||
`{"kind":"row","children":[{"kind":"pip","tone":"ok"},{"kind":"text","value":"quota 62%","tone":"strong"}]}`,
|
||||
`{"kind":"meter","progress":0.62}`,
|
||||
`{"kind":"button","label":"manage","actionId":"open-billing"}`,
|
||||
} {
|
||||
if !strings.Contains(string(raw), want) {
|
||||
t.Errorf("wire JSON missing %s\ngot: %s", want, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,6 +168,8 @@ func DecodeUIPublishPayload(kind UISurfaceKind, raw json.RawMessage) (any, error
|
||||
typ = reflect.TypeFor[UINotificationPayload]()
|
||||
case UISurfacePanel:
|
||||
typ = reflect.TypeFor[UIPanelPayload]()
|
||||
case UISurfaceView:
|
||||
typ = reflect.TypeFor[UIViewPayload]()
|
||||
default:
|
||||
return nil, fmt.Errorf("protocol: unknown UI surface kind %q", kind)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package protocol
|
||||
|
||||
import "fmt"
|
||||
|
||||
// UINodeKind is one primitive a view is composed of. The set is deliberately
|
||||
// small: a host renders these with its own components, so every addition is a
|
||||
// component every frontend must then own. Nothing here describes size,
|
||||
// position, or colour — an extension says what a thing is, never how big.
|
||||
type UINodeKind string
|
||||
|
||||
const (
|
||||
UINodeText UINodeKind = "text"
|
||||
UINodeMarkdown UINodeKind = "markdown"
|
||||
UINodeRow UINodeKind = "row"
|
||||
UINodeStack UINodeKind = "stack"
|
||||
UINodeKV UINodeKind = "kv"
|
||||
UINodeMeter UINodeKind = "meter"
|
||||
UINodePip UINodeKind = "pip"
|
||||
UINodeButton UINodeKind = "button"
|
||||
UINodeDivider UINodeKind = "divider"
|
||||
)
|
||||
|
||||
// UITone is what a node means, not what colour it is. The host owns the
|
||||
// palette: an extension may say "this one is a failure" and may not say
|
||||
// "failures are green".
|
||||
type UITone string
|
||||
|
||||
const (
|
||||
UIToneDim UITone = "dim"
|
||||
UIToneStrong UITone = "strong"
|
||||
UIToneOK UITone = "ok"
|
||||
UIToneWarn UITone = "warn"
|
||||
UIToneErr UITone = "err"
|
||||
UIToneAccent UITone = "accent"
|
||||
)
|
||||
|
||||
// Node tree limits. A published view is rendered synchronously on every
|
||||
// frontend, so the bounds are part of the contract rather than a host's
|
||||
// private defence: an extension that exceeds them gets an error it can fix.
|
||||
const (
|
||||
MaxViewNodes = 200
|
||||
MaxViewDepth = 8
|
||||
MaxViewText = 4000
|
||||
MaxViewLabel = 200
|
||||
MaxViewSlotID = 64
|
||||
)
|
||||
|
||||
// UINode is one node of a view. The shape is flat rather than a union because
|
||||
// every consumer — the strict decoder, the JSON Schema, the generated SDKs —
|
||||
// handles a discriminated struct the same way in every language. Which fields
|
||||
// a kind may carry is enforced in Validate, not by the type.
|
||||
type UINode struct {
|
||||
Kind UINodeKind `json:"kind"`
|
||||
// Value is the text of a text or markdown node, and the right-hand side
|
||||
// of a kv row.
|
||||
Value string `json:"value,omitempty"`
|
||||
// Key is the left-hand side of a kv row.
|
||||
Key string `json:"key,omitempty"`
|
||||
// Label names a button, or annotates a meter.
|
||||
Label string `json:"label,omitempty"`
|
||||
Tone UITone `json:"tone,omitempty"`
|
||||
// Progress is a meter's 0..1 fill.
|
||||
Progress *float64 `json:"progress,omitempty"`
|
||||
// ActionID is the declared action a button invokes.
|
||||
ActionID string `json:"actionId,omitempty"`
|
||||
// Children are the contents of a row or a stack.
|
||||
Children []UINode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// UIViewPayload is a surface an extension composes out of the host's own
|
||||
// primitives instead of filling a fixed shape.
|
||||
type UIViewPayload struct {
|
||||
// Slot is where the view would like to stand: a name, not a position. An
|
||||
// unrecognised one degrades to the host's default place, which is what
|
||||
// lets one extension reach three frontends without knowing which.
|
||||
Slot string `json:"slot,omitempty"`
|
||||
Body []UINode `json:"body"`
|
||||
}
|
||||
|
||||
// Validate walks the tree once, checking the bounds and the fields each kind
|
||||
// is allowed to carry. It runs from the strict decoder, so an extension is
|
||||
// told what is wrong rather than having the offending node silently dropped.
|
||||
func (p UIViewPayload) Validate() error {
|
||||
if len(p.Slot) > MaxViewSlotID {
|
||||
return fmt.Errorf("view slot is longer than %d characters", MaxViewSlotID)
|
||||
}
|
||||
if len(p.Body) == 0 {
|
||||
return fmt.Errorf("view body is empty")
|
||||
}
|
||||
budget := MaxViewNodes
|
||||
for i := range p.Body {
|
||||
if err := validateNode(p.Body[i], 1, &budget); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateNode(n UINode, depth int, budget *int) error {
|
||||
if depth > MaxViewDepth {
|
||||
return fmt.Errorf("view nests deeper than %d levels", MaxViewDepth)
|
||||
}
|
||||
if *budget--; *budget < 0 {
|
||||
return fmt.Errorf("view holds more than %d nodes", MaxViewNodes)
|
||||
}
|
||||
if len(n.Value) > MaxViewText || len(n.Key) > MaxViewLabel || len(n.Label) > MaxViewLabel {
|
||||
return fmt.Errorf("view node %q carries more text than a surface can show", n.Kind)
|
||||
}
|
||||
switch n.Kind {
|
||||
case UINodeText:
|
||||
return requireValue(n, n.Value != "", "text needs a value")
|
||||
case UINodeMarkdown:
|
||||
return requireValue(n, n.Value != "", "markdown needs a value")
|
||||
case UINodeKV:
|
||||
return requireValue(n, n.Key != "", "kv needs a key")
|
||||
case UINodePip:
|
||||
return requireValue(n, n.Tone != "", "pip needs a tone to mean anything")
|
||||
case UINodeButton:
|
||||
return requireValue(n, n.ActionID != "" && n.Label != "", "button needs an actionId and a label")
|
||||
case UINodeMeter:
|
||||
if n.Progress == nil || *n.Progress < 0 || *n.Progress > 1 {
|
||||
return fmt.Errorf("meter needs a progress between 0 and 1")
|
||||
}
|
||||
return requireValue(n, true, "")
|
||||
case UINodeDivider:
|
||||
return requireValue(n, true, "")
|
||||
case UINodeRow, UINodeStack:
|
||||
if len(n.Children) == 0 {
|
||||
return fmt.Errorf("%s has no children", n.Kind)
|
||||
}
|
||||
for i := range n.Children {
|
||||
if err := validateNode(n.Children[i], depth+1, budget); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown view node kind %q", n.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// requireValue enforces the kind's own requirement and then the rule that
|
||||
// applies to every leaf: a node carries the fields of its kind and no others.
|
||||
// A text node with children would render as text on one host and as a
|
||||
// container on the next, which is exactly the drift the primitives exist to
|
||||
// prevent.
|
||||
func requireValue(n UINode, ok bool, why string) error {
|
||||
if !ok {
|
||||
return fmt.Errorf("%s", why)
|
||||
}
|
||||
if len(n.Children) > 0 {
|
||||
return fmt.Errorf("view node %q cannot have children", n.Kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func decodeView(t *testing.T, body string) (UIViewPayload, error) {
|
||||
t.Helper()
|
||||
out, err := DecodeUIPublishPayload(UISurfaceView, json.RawMessage(body))
|
||||
if err != nil {
|
||||
return UIViewPayload{}, err
|
||||
}
|
||||
view, ok := out.(UIViewPayload)
|
||||
if !ok {
|
||||
t.Fatalf("decoded %T, want UIViewPayload", out)
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// The shape an extension actually publishes: a row of primitives, a meter, and
|
||||
// a line of detail. Nothing in it names a size, a position, or a colour.
|
||||
func TestViewDecodesAComposedSurface(t *testing.T) {
|
||||
view, err := decodeView(t, `{
|
||||
"slot": "composer-trailing",
|
||||
"body": [
|
||||
{"kind": "row", "children": [
|
||||
{"kind": "pip", "tone": "ok"},
|
||||
{"kind": "text", "value": "套餐 62%", "tone": "strong"}
|
||||
]},
|
||||
{"kind": "meter", "progress": 0.62},
|
||||
{"kind": "text", "value": "620/1000 · 7 天后重置", "tone": "dim"},
|
||||
{"kind": "button", "actionId": "open-billing", "label": "管理套餐"}
|
||||
]
|
||||
}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view.Slot != "composer-trailing" || len(view.Body) != 4 {
|
||||
t.Fatalf("view = %+v", view)
|
||||
}
|
||||
if got := view.Body[0].Children[1].Value; got != "套餐 62%" {
|
||||
t.Fatalf("nested text = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A slot is a name a host may not know. The protocol takes any name because
|
||||
// the list of places belongs to each frontend, not to this file — an unknown
|
||||
// one degrades where it lands, which is what the host decides, not the decoder.
|
||||
func TestUnknownSlotIsAcceptedAtTheProtocolLevel(t *testing.T) {
|
||||
if _, err := decodeView(t, `{"slot":"somewhere-only-one-host-has","body":[{"kind":"divider"}]}`); err != nil {
|
||||
t.Fatalf("an unfamiliar slot was rejected at decode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewRejectsMalformedNodes(t *testing.T) {
|
||||
cases := []struct{ name, body, want string }{
|
||||
// Caught by the enum table before Validate ever runs, which is why the
|
||||
// registration in validate.go is the load-bearing half: without it the
|
||||
// decoder would hand a node kind nothing knows how to render.
|
||||
{"unknown kind", `{"body":[{"kind":"iframe","value":"x"}]}`, `invalid enum value "iframe"`},
|
||||
{"empty body", `{"body":[]}`, "empty"},
|
||||
{"text without value", `{"body":[{"kind":"text"}]}`, "text needs a value"},
|
||||
{"row without children", `{"body":[{"kind":"row"}]}`, "no children"},
|
||||
{"button without action", `{"body":[{"kind":"button","label":"x"}]}`, "actionId"},
|
||||
{"meter out of range", `{"body":[{"kind":"meter","progress":4}]}`, "between 0 and 1"},
|
||||
{"pip without meaning", `{"body":[{"kind":"pip"}]}`, "tone"},
|
||||
// A leaf carrying children would render as a container on one host and
|
||||
// as a leaf on the next; that drift is what the primitives prevent.
|
||||
{"leaf with children", `{"body":[{"kind":"text","value":"x","children":[{"kind":"divider"}]}]}`, "cannot have children"},
|
||||
// Style is the host's. An extension that could ask for a colour would be
|
||||
// able to paint a failure as a success.
|
||||
{"styling attempt", `{"body":[{"kind":"text","value":"x","color":"#ff0000"}]}`, "do not match"},
|
||||
{"unknown tone", `{"body":[{"kind":"text","value":"x","tone":"neon"}]}`, "tone"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := decodeView(t, tc.body); err == nil {
|
||||
t.Fatal("accepted a malformed view")
|
||||
} else if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want it to mention %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The bounds are part of the contract because every frontend renders a
|
||||
// published view synchronously: a tree that is too deep or too large is a
|
||||
// frozen window, so the extension is told rather than the host defending.
|
||||
func TestViewBoundsAreEnforced(t *testing.T) {
|
||||
deep := `{"kind":"divider"}`
|
||||
for range MaxViewDepth + 1 {
|
||||
deep = fmt.Sprintf(`{"kind":"stack","children":[%s]}`, deep)
|
||||
}
|
||||
if _, err := decodeView(t, `{"body":[`+deep+`]}`); err == nil {
|
||||
t.Error("a tree deeper than the limit was accepted")
|
||||
}
|
||||
|
||||
wide := make([]string, MaxViewNodes+1)
|
||||
for i := range wide {
|
||||
wide[i] = `{"kind":"divider"}`
|
||||
}
|
||||
if _, err := decodeView(t, `{"body":[`+strings.Join(wide, ",")+`]}`); err == nil {
|
||||
t.Error("a tree with more nodes than the limit was accepted")
|
||||
}
|
||||
|
||||
if _, err := decodeView(t, `{"body":[{"kind":"text","value":"`+strings.Repeat("x", MaxViewText+1)+`"}]}`); err == nil {
|
||||
t.Error("a text node past the length limit was accepted")
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,10 @@ const (
|
||||
// side rail instead of scrolling past in the transcript, so re-publishing
|
||||
// the same surface id updates what is already on screen.
|
||||
UISurfacePanel UISurfaceKind = "panel"
|
||||
// UISurfaceView is composed rather than filled in: its payload is a tree of
|
||||
// host primitives, so an extension that needs a shape the fixed kinds do
|
||||
// not have builds one without the protocol growing a kind for it.
|
||||
UISurfaceView UISurfaceKind = "view"
|
||||
)
|
||||
|
||||
// UIRequestKind is the kind of blocking UI prompt the host shows on an
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"$defs":{"CapabilityWire":{"additionalProperties":false,"properties":{"id":{"minLength":1,"type":"string"},"kind":{"minLength":1,"type":"string"},"namespace":{"minLength":1,"type":"string"},"schemaHash":{"type":"string"},"version":{"type":"string"}},"required":["id","kind","namespace"],"type":"object"},"ContentReadParams":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"offset":{"minimum":0,"type":"integer"}},"required":["contentRef","offset"],"type":"object"},"ContentReadResult":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"dataBase64":{"type":"string"},"encoding":{"enum":["utf8"],"type":"string"},"nextOffset":{"minimum":0,"type":"integer"},"offset":{"minimum":0,"type":"integer"},"sha256":{"type":"string"},"totalBytes":{"minimum":0,"type":"integer"}},"required":["contentRef","dataBase64","encoding","offset","sha256","totalBytes"],"type":"object"},"EventParams":{"additionalProperties":false,"properties":{"event":{"enum":["agent.before_start","compaction.complete","compaction.prepare","context.prepare","frontend.event","input.receive","permission.decision","provider.request","provider.response","session.end","session.load","session.rotate","session.save","session.start","system_prompt.build","tool.after","tool.before"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"payload":{"x-externalizable":true}},"required":["event","payload"],"type":"object"},"ExternalizedField":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"jsonPointer":{"minLength":1,"type":"string"},"sha256":{"type":"string"},"totalBytes":{"minimum":0,"type":"integer"}},"required":["contentRef","jsonPointer","sha256","totalBytes"],"type":"object"},"HostCapabilities":{"additionalProperties":false,"properties":{"contentRefs":{"type":"boolean"},"dependencySchemaVersion":{"minimum":0,"type":"integer"},"protocolVersion":{"minLength":1,"type":"string"},"uiHost":{"enum":["acp","desktop","headless","tui"],"type":"string"}},"required":["contentRefs","protocolVersion","uiHost"],"type":"object"},"InitializeParams":{"additionalProperties":false,"properties":{"capabilities":{"$ref":"#/$defs/HostCapabilities"},"dependencySchemaVersion":{"minimum":0,"type":"integer"},"manifest":{"$ref":"#/$defs/ManifestExpectation"},"protocolId":{"minLength":1,"type":"string"},"protocolVersion":{"minLength":1,"type":"string"},"session":{"$ref":"#/$defs/SessionContext"}},"required":["capabilities","manifest","protocolId","protocolVersion","session"],"type":"object"},"InitializeResult":{"additionalProperties":false,"properties":{"componentId":{"type":"string"},"name":{"minLength":1,"type":"string"},"protocolVersion":{"minLength":1,"type":"string"},"providers":{"items":{"$ref":"#/$defs/ProviderDescriptor"},"type":"array"},"provides":{"items":{"$ref":"#/$defs/CapabilityWire"},"type":"array"},"replaces":{"items":{"type":"string"},"type":"array"},"requires":{"items":{"$ref":"#/$defs/RequirementWire"},"type":"array"},"stateSchemaVersion":{"minimum":0,"type":"integer"},"subscriptions":{"items":{"type":"string"},"type":"array"},"uiActions":{"items":{"$ref":"#/$defs/UIActionDecl"},"type":"array"},"version":{"minLength":1,"type":"string"}},"required":["name","protocolVersion","stateSchemaVersion","version"],"type":"object"},"InitializedParams":{"additionalProperties":false,"properties":{},"type":"object"},"InterceptParams":{"additionalProperties":false,"properties":{"event":{"enum":["agent.before_start","compaction.complete","compaction.prepare","context.prepare","frontend.event","input.receive","permission.decision","provider.request","provider.response","session.end","session.load","session.rotate","session.save","session.start","system_prompt.build","tool.after","tool.before"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"payload":{"x-externalizable":true},"seq":{"minimum":1,"type":"integer"},"timeoutMillis":{"minimum":0,"type":"integer"}},"required":["event","payload","seq","timeoutMillis"],"type":"object"},"InterceptResult":{"additionalProperties":false,"properties":{"decision":{"enum":["allow","block","continue","deny","replace"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"reason":{"type":"string"},"replacement":{"x-externalizable":true}},"required":["decision"],"type":"object"},"ManifestExpectation":{"additionalProperties":false,"properties":{"capabilities":{"items":{"type":"string"},"type":"array"},"intercepts":{"items":{"type":"string"},"type":"array"},"providers":{"items":{"type":"string"},"type":"array"},"provides":{"items":{"$ref":"#/$defs/CapabilityWire"},"type":"array"},"replaces":{"items":{"type":"string"},"type":"array"},"requires":{"items":{"$ref":"#/$defs/RequirementWire"},"type":"array"},"uiActions":{"items":{"type":"string"},"type":"array"}},"type":"object"},"ProviderCatalogParams":{"additionalProperLine truncated
|
||||
{"$defs":{"CapabilityWire":{"additionalProperties":false,"properties":{"id":{"minLength":1,"type":"string"},"kind":{"minLength":1,"type":"string"},"namespace":{"minLength":1,"type":"string"},"schemaHash":{"type":"string"},"version":{"type":"string"}},"required":["id","kind","namespace"],"type":"object"},"ContentReadParams":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"offset":{"minimum":0,"type":"integer"}},"required":["contentRef","offset"],"type":"object"},"ContentReadResult":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"dataBase64":{"type":"string"},"encoding":{"enum":["utf8"],"type":"string"},"nextOffset":{"minimum":0,"type":"integer"},"offset":{"minimum":0,"type":"integer"},"sha256":{"type":"string"},"totalBytes":{"minimum":0,"type":"integer"}},"required":["contentRef","dataBase64","encoding","offset","sha256","totalBytes"],"type":"object"},"EventParams":{"additionalProperties":false,"properties":{"event":{"enum":["agent.before_start","compaction.complete","compaction.prepare","context.prepare","frontend.event","input.receive","permission.decision","provider.request","provider.response","session.end","session.load","session.rotate","session.save","session.start","system_prompt.build","tool.after","tool.before"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"payload":{"x-externalizable":true}},"required":["event","payload"],"type":"object"},"ExternalizedField":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"jsonPointer":{"minLength":1,"type":"string"},"sha256":{"type":"string"},"totalBytes":{"minimum":0,"type":"integer"}},"required":["contentRef","jsonPointer","sha256","totalBytes"],"type":"object"},"HostCapabilities":{"additionalProperties":false,"properties":{"contentRefs":{"type":"boolean"},"dependencySchemaVersion":{"minimum":0,"type":"integer"},"protocolVersion":{"minLength":1,"type":"string"},"uiHost":{"enum":["acp","desktop","headless","tui"],"type":"string"}},"required":["contentRefs","protocolVersion","uiHost"],"type":"object"},"InitializeParams":{"additionalProperties":false,"properties":{"capabilities":{"$ref":"#/$defs/HostCapabilities"},"dependencySchemaVersion":{"minimum":0,"type":"integer"},"manifest":{"$ref":"#/$defs/ManifestExpectation"},"protocolId":{"minLength":1,"type":"string"},"protocolVersion":{"minLength":1,"type":"string"},"session":{"$ref":"#/$defs/SessionContext"}},"required":["capabilities","manifest","protocolId","protocolVersion","session"],"type":"object"},"InitializeResult":{"additionalProperties":false,"properties":{"componentId":{"type":"string"},"name":{"minLength":1,"type":"string"},"protocolVersion":{"minLength":1,"type":"string"},"providers":{"items":{"$ref":"#/$defs/ProviderDescriptor"},"type":"array"},"provides":{"items":{"$ref":"#/$defs/CapabilityWire"},"type":"array"},"replaces":{"items":{"type":"string"},"type":"array"},"requires":{"items":{"$ref":"#/$defs/RequirementWire"},"type":"array"},"stateSchemaVersion":{"minimum":0,"type":"integer"},"subscriptions":{"items":{"type":"string"},"type":"array"},"uiActions":{"items":{"$ref":"#/$defs/UIActionDecl"},"type":"array"},"version":{"minLength":1,"type":"string"}},"required":["name","protocolVersion","stateSchemaVersion","version"],"type":"object"},"InitializedParams":{"additionalProperties":false,"properties":{},"type":"object"},"InterceptParams":{"additionalProperties":false,"properties":{"event":{"enum":["agent.before_start","compaction.complete","compaction.prepare","context.prepare","frontend.event","input.receive","permission.decision","provider.request","provider.response","session.end","session.load","session.rotate","session.save","session.start","system_prompt.build","tool.after","tool.before"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"payload":{"x-externalizable":true},"seq":{"minimum":1,"type":"integer"},"timeoutMillis":{"minimum":0,"type":"integer"}},"required":["event","payload","seq","timeoutMillis"],"type":"object"},"InterceptResult":{"additionalProperties":false,"properties":{"decision":{"enum":["allow","block","continue","deny","replace"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"reason":{"type":"string"},"replacement":{"x-externalizable":true}},"required":["decision"],"type":"object"},"ManifestExpectation":{"additionalProperties":false,"properties":{"capabilities":{"items":{"type":"string"},"type":"array"},"intercepts":{"items":{"type":"string"},"type":"array"},"providers":{"items":{"type":"string"},"type":"array"},"provides":{"items":{"$ref":"#/$defs/CapabilityWire"},"type":"array"},"replaces":{"items":{"type":"string"},"type":"array"},"requires":{"items":{"$ref":"#/$defs/RequirementWire"},"type":"array"},"uiActions":{"items":{"type":"string"},"type":"array"}},"type":"object"},"ProviderCatalogParams":{"additionalProperLine truncated
|
||||
@@ -5,4 +5,4 @@ package protocol
|
||||
// GeneratedSchemaHash is the SHA-256 of schema.generated.json. Handshake
|
||||
// comparisons use this constant; protocol tests independently recompute it
|
||||
// from CanonicalSchemaBytes to reject stale generated artifacts.
|
||||
const GeneratedSchemaHash = "sha256:74469060337755e305098bc21d75b0255028e12043f411a4408f2344be83548f"
|
||||
const GeneratedSchemaHash = "sha256:c19003186419665354115b5a71e525423303edf788b0738ebcbfd6427732001b"
|
||||
@@ -35,7 +35,9 @@ var enumTypes = map[reflect.Type][]string{
|
||||
reflect.TypeFor[InterceptEvent](): interceptEventValues(),
|
||||
reflect.TypeFor[InterceptDecision](): values(DecisionContinue, DecisionBlock, DecisionReplace, DecisionAllow, DecisionDeny),
|
||||
reflect.TypeFor[UIHostKind](): values(UIHostTUI, UIHostDesktop, UIHostACP, UIHostHeadless),
|
||||
reflect.TypeFor[UISurfaceKind](): values(UISurfaceStatus, UISurfaceCard, UISurfaceForm, UISurfaceNotification, UISurfacePanel),
|
||||
reflect.TypeFor[UISurfaceKind](): values(UISurfaceStatus, UISurfaceCard, UISurfaceForm, UISurfaceNotification, UISurfacePanel, UISurfaceView),
|
||||
reflect.TypeFor[UINodeKind](): values(UINodeText, UINodeMarkdown, UINodeRow, UINodeStack, UINodeKV, UINodeMeter, UINodePip, UINodeButton, UINodeDivider),
|
||||
reflect.TypeFor[UITone](): values(UIToneDim, UIToneStrong, UIToneOK, UIToneWarn, UIToneErr, UIToneAccent),
|
||||
reflect.TypeFor[UIRequestKind](): values(UIRequestConfirm, UIRequestInput, UIRequestSelect, UIRequestMultiselect),
|
||||
reflect.TypeFor[UIFieldKind](): values(UIFieldConfirm, UIFieldInput, UIFieldSelect, UIFieldMultiselect),
|
||||
reflect.TypeFor[UISeverity](): values(UISeverityInfo, UISeverityWarn, UISeverityError),
|
||||
|
||||
@@ -28,6 +28,8 @@ var sdkEnumPrefixes = map[string]string{
|
||||
"UISurfaceKind": "UISurface",
|
||||
"UIRequestKind": "UIRequest",
|
||||
"UIFieldKind": "UIField",
|
||||
"UINodeKind": "UINode",
|
||||
"UITone": "UITone",
|
||||
"UISeverity": "UISeverity",
|
||||
"ProviderRole": "ProviderRole",
|
||||
"ProviderChunkType": "Chunk",
|
||||
@@ -82,6 +84,7 @@ func walkSDKTypes() (*sdkTypeWalk, error) {
|
||||
reflect.TypeFor[protocol.UIFormPayload](),
|
||||
reflect.TypeFor[protocol.UINotificationPayload](),
|
||||
reflect.TypeFor[protocol.UIPanelPayload](),
|
||||
reflect.TypeFor[protocol.UIViewPayload](),
|
||||
reflect.TypeFor[protocol.ProtocolErrorData](),
|
||||
)
|
||||
for _, root := range roots {
|
||||
|
||||
@@ -547,6 +547,8 @@ func decodePublishEvent(pluginID string, p protocol.UIPublishParams) (*event.Ext
|
||||
})
|
||||
}
|
||||
out.Panel = panel
|
||||
case protocol.UIViewPayload:
|
||||
out.View = &event.ExtensionViewSurface{Slot: payload.Slot, Body: redactViewNodes(payload.Body)}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -732,3 +734,26 @@ func askOptions(options []string) []event.AskOption {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// redactViewNodes walks a published tree, redacting every string a frontend
|
||||
// will show. The walk is bounded by the protocol's own depth and node limits,
|
||||
// which the strict decoder has already enforced by the time a payload arrives.
|
||||
func redactViewNodes(in []protocol.UINode) []event.ExtensionViewNode {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]event.ExtensionViewNode, 0, len(in))
|
||||
for _, node := range in {
|
||||
out = append(out, event.ExtensionViewNode{
|
||||
Kind: string(node.Kind),
|
||||
Value: secrets.RedactCredentials(node.Value),
|
||||
Key: secrets.RedactCredentials(node.Key),
|
||||
Label: secrets.RedactCredentials(node.Label),
|
||||
Tone: string(node.Tone),
|
||||
Progress: node.Progress,
|
||||
ActionID: node.ActionID,
|
||||
Children: redactViewNodes(node.Children),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package uihub
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/extension/protocol"
|
||||
)
|
||||
|
||||
// A view arrives as a tree, and every string in it is shown to the user — so
|
||||
// the redaction has to reach the leaves, not just the top level. A credential
|
||||
// pasted into a nested label is exactly where one would survive an
|
||||
// implementation that only walked the first row.
|
||||
func TestPublishViewRedactsEveryLeaf(t *testing.T) {
|
||||
rec := &eventRecorder{}
|
||||
h := newTestHub(rec)
|
||||
progress := 0.62
|
||||
result := publishRaw(t, h, "alpha", protocol.UIPublishParams{
|
||||
SurfaceID: "usage", SessionID: "sess-1", Generation: 7, Kind: protocol.UISurfaceView,
|
||||
Payload: mustRaw(t, protocol.UIViewPayload{
|
||||
Slot: "composer-trailing",
|
||||
Body: []protocol.UINode{
|
||||
{Kind: protocol.UINodeRow, Children: []protocol.UINode{
|
||||
{Kind: protocol.UINodePip, Tone: protocol.UIToneOK},
|
||||
{Kind: protocol.UINodeText, Value: "quota " + testCredential, Tone: protocol.UIToneStrong},
|
||||
}},
|
||||
{Kind: protocol.UINodeMeter, Progress: &progress},
|
||||
{Kind: protocol.UINodeKV, Key: "key " + testCredential, Value: "value " + testCredential},
|
||||
},
|
||||
}),
|
||||
})
|
||||
if !result.Accepted {
|
||||
t.Fatal("view publish not accepted")
|
||||
}
|
||||
events := rec.all()
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("emitted %d events, want 1", len(events))
|
||||
}
|
||||
view := events[0].Extension.View
|
||||
if view == nil || view.Slot != "composer-trailing" || len(view.Body) != 3 {
|
||||
t.Fatalf("view = %+v", view)
|
||||
}
|
||||
if events[0].Extension.Kind != event.ExtensionSurfaceView {
|
||||
t.Fatalf("payload kind = %q", events[0].Extension.Kind)
|
||||
}
|
||||
nested := view.Body[0].Children[1]
|
||||
if nested.Kind != "text" || nested.Tone != "strong" {
|
||||
t.Fatalf("nested node = %+v", nested)
|
||||
}
|
||||
kv := view.Body[2]
|
||||
for _, s := range []string{nested.Value, kv.Key, kv.Value} {
|
||||
if strings.Contains(s, "sk-abcdef") || !strings.Contains(s, "****") {
|
||||
t.Fatalf("view text not redacted: %q", s)
|
||||
}
|
||||
}
|
||||
if view.Body[1].Progress == nil || *view.Body[1].Progress != 0.62 {
|
||||
t.Fatalf("meter = %+v", view.Body[1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// archive.go — a plugin package handed over as a local .zip.
|
||||
package installsource
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// looksLikeZip reports a local archive source. Exporting a package produces
|
||||
// one, and installing it is the same act as installing the folder it unpacks
|
||||
// to — there is no separate import path for a package to arrive through.
|
||||
func looksLikeZip(path string) bool {
|
||||
if !strings.EqualFold(filepath.Ext(path), ".zip") {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info.Mode().IsRegular()
|
||||
}
|
||||
|
||||
// unpackPluginZip extracts path into a temporary directory and returns the
|
||||
// package root inside it: the single directory every entry shares, or the
|
||||
// extraction directory itself when the archive was packed flat.
|
||||
func unpackPluginZip(path string) (string, func(), error) {
|
||||
zr, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
return "", func() {}, newErr(ErrSourceUnreadable, "read %s: %v", filepath.Base(path), err)
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
dir, err := os.MkdirTemp("", "reasonix-plugin-zip-*")
|
||||
if err != nil {
|
||||
return "", func() {}, err
|
||||
}
|
||||
cleanup := func() { _ = os.RemoveAll(dir) }
|
||||
|
||||
files := 0
|
||||
var total int64
|
||||
root := ""
|
||||
rooted := true
|
||||
for _, entry := range zr.File {
|
||||
// Only regular files are recreated. A symlink inside an archive from
|
||||
// somewhere else is a path onto this machine, not package content.
|
||||
if entry.FileInfo().IsDir() || !entry.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
rel := filepath.FromSlash(entry.Name)
|
||||
if !filepath.IsLocal(rel) {
|
||||
cleanup()
|
||||
return "", func() {}, newErr(ErrUnsupportedKind, "zip entry %q escapes the extraction directory", entry.Name)
|
||||
}
|
||||
head, _, _ := strings.Cut(filepath.ToSlash(rel), "/")
|
||||
if root == "" {
|
||||
root = head
|
||||
}
|
||||
if head != root || !strings.Contains(filepath.ToSlash(rel), "/") {
|
||||
rooted = false
|
||||
}
|
||||
files++
|
||||
if files > tarballFileLimit {
|
||||
cleanup()
|
||||
return "", func() {}, newErr(ErrSourceUnreadable, "zip holds more than %d files", tarballFileLimit)
|
||||
}
|
||||
dest := filepath.Join(dir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
cleanup()
|
||||
return "", func() {}, newErr(ErrSourceUnreadable, "create %s: %v", filepath.Dir(rel), err)
|
||||
}
|
||||
rc, err := entry.Open()
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return "", func() {}, newErr(ErrSourceUnreadable, "read %s: %v", entry.Name, err)
|
||||
}
|
||||
written, err := writeTarEntry(dest, rc, int64(entry.Mode().Perm()))
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return "", func() {}, err
|
||||
}
|
||||
total += written
|
||||
if total > tarballTotalLimit {
|
||||
cleanup()
|
||||
return "", func() {}, newErr(ErrSourceUnreadable, "zip expands past %d bytes", tarballTotalLimit)
|
||||
}
|
||||
}
|
||||
if files == 0 {
|
||||
cleanup()
|
||||
return "", func() {}, newErr(ErrSourceUnreadable, "zip is empty")
|
||||
}
|
||||
if rooted && root != "" {
|
||||
return filepath.Join(dir, root), cleanup, nil
|
||||
}
|
||||
return dir, cleanup, nil
|
||||
}
|
||||
|
||||
// planZipPlugin plans from the archive's unpacked contents while keeping the
|
||||
// archive itself as the action's source, so apply unpacks the same file the
|
||||
// user approved rather than a temporary directory that is already gone.
|
||||
func (t *installSourceTool) planZipPlugin(req request, path string) ([]action, []string, error) {
|
||||
if req.Kind != "auto" && req.Kind != "plugin" {
|
||||
return nil, nil, newErr(ErrUnsupportedKind, "%s is a plugin package archive, not a %s source", filepath.Base(path), req.Kind)
|
||||
}
|
||||
root, cleanup, err := unpackPluginZip(path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer cleanup()
|
||||
act, warnings, err := t.localPluginPackageAction(req, root)
|
||||
if err != nil {
|
||||
return nil, warnings, err
|
||||
}
|
||||
act.Source = path
|
||||
return []action{act}, warnings, nil
|
||||
}
|
||||
|
||||
func (t *installSourceTool) preparePluginZip(path, mode string) (string, string, func(), error) {
|
||||
// A link is a live pointer at a directory the author keeps editing. An
|
||||
// archive has no such directory — linking one would point at a temporary
|
||||
// copy this call is about to delete.
|
||||
if mode == "link" {
|
||||
return "", "", func() {}, newErr(ErrUnsupportedKind, "a .zip cannot be linked; install it without link mode")
|
||||
}
|
||||
root, cleanup, err := unpackPluginZip(path)
|
||||
if err != nil {
|
||||
return "", "", func() {}, err
|
||||
}
|
||||
return root, "", cleanup, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package installsource
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/pluginpkg"
|
||||
)
|
||||
|
||||
// The round trip is the point: what an export produces is what an install
|
||||
// accepts. If these two ever drift, the archive a user was handed becomes a
|
||||
// file they cannot do anything with.
|
||||
func TestInstallAcceptsAnExportedZip(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
writeFile(t, filepath.Join(src, "reasonix-plugin.json"), `{
|
||||
"apiVersion": "reasonix.io/plugin/v2",
|
||||
"name": "roundtrip",
|
||||
"version": "1.0.0",
|
||||
"skills": ["skills"],
|
||||
"mcpServers": {"docs": {"command": "docs", "env": {"TOKEN": "sk-live-1"}}}
|
||||
}`)
|
||||
writeFile(t, filepath.Join(src, "skills", "greet", "SKILL.md"),
|
||||
"---\nname: greet\ndescription: say hello\n---\nHello")
|
||||
|
||||
archive, required, err := pluginpkg.Export("roundtrip", src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(required) != 1 || required[0] != "DOCS_TOKEN" {
|
||||
t.Fatalf("required = %v", required)
|
||||
}
|
||||
zipPath := filepath.Join(t.TempDir(), "roundtrip.zip")
|
||||
if err := os.WriteFile(zipPath, archive, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
home := t.TempDir()
|
||||
tl := NewTool(Options{ProjectRoot: t.TempDir(), HomeDir: home})
|
||||
resp := execInstall(t, tl, map[string]any{"source": zipPath, "kind": "plugin", "apply": true})
|
||||
if !resp.OK || resp.Status != "done" {
|
||||
t.Fatalf("response = %+v", resp)
|
||||
}
|
||||
|
||||
st, err := pluginpkg.LoadState(filepath.Join(home, ".reasonix"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(st.Plugins) != 1 || st.Plugins[0].Name != "roundtrip" {
|
||||
t.Fatalf("state = %+v", st.Plugins)
|
||||
}
|
||||
// The credential did not survive the export, so it must not be back after
|
||||
// the install either.
|
||||
manifest, err := os.ReadFile(filepath.Join(pluginpkg.InstallRoot(filepath.Join(home, ".reasonix"), "roundtrip"), pluginpkg.NativeManifest))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(manifest), "sk-live-1") {
|
||||
t.Fatalf("installed manifest carries the literal credential:\n%s", manifest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZipCannotBeLinked(t *testing.T) {
|
||||
zipPath := filepath.Join(t.TempDir(), "x.zip")
|
||||
if err := os.WriteFile(zipPath, []byte("PK"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tl := &installSourceTool{}
|
||||
if _, _, _, err := tl.preparePluginZip(zipPath, "link"); err == nil {
|
||||
t.Fatal("link mode accepted an archive, whose unpacked copy is deleted after the call")
|
||||
}
|
||||
}
|
||||
@@ -57,16 +57,21 @@ type Options struct {
|
||||
ConnectMCP MCPConnector
|
||||
OnDisconnect OnDisconnectFunc
|
||||
Approval ApprovalFunc
|
||||
// RequireApprovedPlan refuses an apply that does not present back the planId
|
||||
// a preview handed out. On for the model, whose apply would otherwise be the
|
||||
// first moment anyone could learn what the source holds; off for hosts.
|
||||
RequireApprovedPlan bool
|
||||
}
|
||||
|
||||
type installSourceTool struct {
|
||||
root string
|
||||
home string
|
||||
reasonixHome string
|
||||
httpClient *http.Client
|
||||
connectMCP MCPConnector
|
||||
onDisconnect OnDisconnectFunc
|
||||
approval ApprovalFunc
|
||||
root string
|
||||
home string
|
||||
reasonixHome string
|
||||
httpClient *http.Client
|
||||
connectMCP MCPConnector
|
||||
onDisconnect OnDisconnectFunc
|
||||
approval ApprovalFunc
|
||||
requireApprovedPlan bool
|
||||
// preparePlugin overrides plugin source preparation in tests. nil uses
|
||||
// preparePluginSource. Plan and apply both resolve the source through the
|
||||
// same function, and git sources additionally report the resolved commit,
|
||||
@@ -111,13 +116,14 @@ func NewTool(opts Options) tool.Tool {
|
||||
// prompt-injected source can't reach cloud metadata / internal services.
|
||||
client = ssrfGuardClient(client)
|
||||
return &installSourceTool{
|
||||
root: root,
|
||||
home: home,
|
||||
reasonixHome: reasonixHome,
|
||||
httpClient: client,
|
||||
connectMCP: opts.ConnectMCP,
|
||||
onDisconnect: opts.OnDisconnect,
|
||||
approval: opts.Approval,
|
||||
root: root,
|
||||
home: home,
|
||||
reasonixHome: reasonixHome,
|
||||
httpClient: client,
|
||||
connectMCP: opts.ConnectMCP,
|
||||
onDisconnect: opts.OnDisconnect,
|
||||
approval: opts.Approval,
|
||||
requireApprovedPlan: opts.RequireApprovedPlan,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +131,7 @@ func (*installSourceTool) Name() string { return "install_source" }
|
||||
func (*installSourceTool) ReadOnly() bool { return false }
|
||||
|
||||
func (*installSourceTool) Description() string {
|
||||
return "Plan, install, or uninstall a Reasonix skill, MCP server, or plugin package from a URL, local file/folder, .mcp.json, executable, or package name. Two-phase: with apply=false (default) returns a deterministic plan with per-action risk level; with apply=true copies/registers skills, connects/persists MCP servers, or installs plugin packages after validation. op='uninstall' removes a previously installed skill, MCP server, or plugin package by name."
|
||||
return "Plan, install, or uninstall a Reasonix skill, MCP server, or plugin package from a URL, local file/folder, .mcp.json, executable, or package name. Two-phase: with apply=false (default) returns a deterministic plan with per-action risk level and a planId; with apply=true, passing back that planId, it copies/registers skills, connects/persists MCP servers, or installs plugin packages. An apply without the planId of a plan you have read installs nothing and returns the plan instead. op='uninstall' removes a previously installed skill, MCP server, or plugin package by name."
|
||||
}
|
||||
|
||||
func (*installSourceTool) Schema() json.RawMessage {
|
||||
@@ -147,7 +153,7 @@ func (*installSourceTool) Schema() json.RawMessage {
|
||||
"tier":{"type":"string","enum":["background","eager"],"description":"Persisted MCP startup tier. Defaults to background."},
|
||||
"replace":{"type":"boolean","description":"Allow replacing an existing MCP config entry with the same name. Skills still refuse to overwrite existing files."},
|
||||
"strict":{"type":"boolean","description":"Skill install strictness. true (default) requires name+description frontmatter; false copies the file as-is (use only for files you trust)."},
|
||||
"planId":{"type":"string","description":"Optional. Echoed from a previous planned response to confirm the host is approving the same plan."}
|
||||
"planId":{"type":"string","description":"The planId from the plan being applied. Required with apply=true: it proves the plan was read, and its leading risk level (low/medium/high) is what the permission layer decides on."}
|
||||
},
|
||||
"required":[]
|
||||
}`)
|
||||
@@ -220,7 +226,11 @@ func (t *installSourceTool) Execute(ctx context.Context, raw json.RawMessage) (s
|
||||
return marshalJSON(out), nil
|
||||
}
|
||||
|
||||
if !req.Apply {
|
||||
// An apply with no ticket is answered with the plan instead of an error: the
|
||||
// caller wanted to install, and what it is missing is exactly what this
|
||||
// returns. Nothing was written, so "planned" is the honest status.
|
||||
unticketed := req.Apply && t.requireApprovedPlan && strings.TrimSpace(req.PlanID) == ""
|
||||
if !req.Apply || unticketed {
|
||||
for i := range actions {
|
||||
actions[i].Status = "planned"
|
||||
}
|
||||
@@ -238,7 +248,7 @@ func (t *installSourceTool) Execute(ctx context.Context, raw json.RawMessage) (s
|
||||
PlanID: planID,
|
||||
Actions: publicActions(actions),
|
||||
Warnings: warnings,
|
||||
Next: "Review the plan (especially each action's riskLevel). Call install_source again with apply=true and the same planId to install.",
|
||||
Next: planNext(unticketed),
|
||||
}
|
||||
return marshalJSON(out), nil
|
||||
}
|
||||
@@ -675,7 +685,41 @@ func computePlanID(req request, actions []action) string {
|
||||
body, _ := json.Marshal(payload)
|
||||
h := sha256.New()
|
||||
h.Write(body)
|
||||
return "sha256:" + hex.EncodeToString(h.Sum(nil)[:16])
|
||||
// The grade leads the hash because the plan ID is what the permission layer
|
||||
// matches on: a rule can name install_source(low:*) without that layer
|
||||
// learning what a plan contains.
|
||||
return string(planRisk(actions)) + ":sha256:" + hex.EncodeToString(h.Sum(nil)[:16])
|
||||
}
|
||||
|
||||
func planNext(unticketed bool) string {
|
||||
if unticketed {
|
||||
return "Nothing was installed: apply=true must carry the planId of a plan that has been reviewed. Read this plan (especially each action's riskLevel), then call install_source again with apply=true and this planId."
|
||||
}
|
||||
return "Review the plan (especially each action's riskLevel). Call install_source again with apply=true and the same planId to install."
|
||||
}
|
||||
|
||||
// planRisk is the whole plan's grade: the worst of its actions. A plan is
|
||||
// approved or refused as one, so a low-risk skill copy riding alongside a
|
||||
// package that starts a process must not soften what the ticket says.
|
||||
func planRisk(actions []action) RiskLevel {
|
||||
worst := RiskLow
|
||||
for _, a := range actions {
|
||||
if riskRank(a.RiskLevel) > riskRank(worst) {
|
||||
worst = a.RiskLevel
|
||||
}
|
||||
}
|
||||
return worst
|
||||
}
|
||||
|
||||
func riskRank(level RiskLevel) int {
|
||||
switch level {
|
||||
case RiskHigh:
|
||||
return 2
|
||||
case RiskMedium:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// kindCounts tallies the per-kind action count for the response. Skill
|
||||
|
||||
@@ -350,6 +350,12 @@ func joinURLPath(parts ...string) string {
|
||||
func (t *installSourceTool) planLocal(req request, path string, info os.FileInfo) ([]action, []string, error) {
|
||||
var actions []action
|
||||
var warnings []string
|
||||
// An exported package arrives as an archive rather than a folder, and it can
|
||||
// be nothing else — no skill file or .mcp.json is a zip — so this is the
|
||||
// whole answer for one rather than a first guess to fall through from.
|
||||
if !info.IsDir() && looksLikeZip(path) {
|
||||
return t.planZipPlugin(req, path)
|
||||
}
|
||||
if info.IsDir() && (req.Kind == "auto" || req.Kind == "plugin") {
|
||||
pluginAction, pluginWarnings, err := t.localPluginPackageAction(req, path)
|
||||
if err == nil {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package installsource
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/permission"
|
||||
"reasonix/internal/pluginpkg"
|
||||
)
|
||||
|
||||
// writeHookedPlugin lays out a package whose hooks execute during sessions,
|
||||
// which is what grades a plan high.
|
||||
func writeHookedPlugin(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
writeFile(t, filepath.Join(root, "reasonix-plugin.json"), `{
|
||||
"apiVersion": "reasonix.io/plugin/v2",
|
||||
"name": "ticketed",
|
||||
"version": "1.0.0",
|
||||
"skills": ["skills"],
|
||||
"hooks": {"SessionStart": [{"command": "hooks/start.sh"}]}
|
||||
}`)
|
||||
writeFile(t, filepath.Join(root, "skills", "greet", "SKILL.md"),
|
||||
"---\nname: greet\ndescription: say hello\n---\nHello")
|
||||
writeFile(t, filepath.Join(root, "hooks", "start.sh"), "#!/bin/sh\n")
|
||||
}
|
||||
|
||||
func applyArgs(t *testing.T, source, planID string) json.RawMessage {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"source": source, "kind": "plugin", "apply": true, "planId": planID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// The permission layer refuses to auto-approve a plan graded high, and this
|
||||
// tool is what writes that grade into the ticket. The two live in packages that
|
||||
// cannot import each other, so the agreement is only real here.
|
||||
func TestHighRiskPlanAsksEvenUnderBlanketAllow(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
writeHookedPlugin(t, src)
|
||||
tl := NewTool(Options{ProjectRoot: t.TempDir(), HomeDir: t.TempDir(), RequireApprovedPlan: true})
|
||||
|
||||
resp := execInstall(t, tl, map[string]any{"source": src, "kind": "plugin"})
|
||||
if !resp.OK || resp.Status != "planned" {
|
||||
t.Fatalf("plan = %+v", resp)
|
||||
}
|
||||
if !strings.HasPrefix(resp.PlanID, "high:") {
|
||||
t.Fatalf("planId = %q, want the plan's grade in front of the hash", resp.PlanID)
|
||||
}
|
||||
|
||||
// "allow everything" is the yolo posture: it covers this workspace's files,
|
||||
// not the agent handing itself a lifecycle hook.
|
||||
blanket := permission.New("allow", nil, nil, nil)
|
||||
if got := blanket.Decide("install_source", false, applyArgs(t, src, resp.PlanID)); got != permission.Ask {
|
||||
t.Fatalf("blanket allow decided %v for a high-risk self-extension, want Ask", got)
|
||||
}
|
||||
// A user who means it moves the line with a rule; the blanket alone does not.
|
||||
explicit := permission.New("allow", []string{"install_source(high:*)"}, nil, nil)
|
||||
if got := explicit.Decide("install_source", false, applyArgs(t, src, resp.PlanID)); got != permission.Allow {
|
||||
t.Fatalf("an explicit rule for the ticket decided %v, want Allow", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Previewing is a read: it reports what a source contains and writes nothing.
|
||||
func TestPlanOnlyCallIsAReadEvenInAskMode(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
writeHookedPlugin(t, src)
|
||||
raw, err := json.Marshal(map[string]any{"source": src, "kind": "plugin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ask := permission.New("ask", nil, nil, nil)
|
||||
gate := permission.NewGate(ask, nil)
|
||||
if !permission.InstallSourceIsPlanOnly(raw) {
|
||||
t.Fatal("a call with no apply was not recognized as a preview")
|
||||
}
|
||||
if allow, reason, err := gate.Check(t.Context(), "install_source", raw, false); err != nil || !allow {
|
||||
t.Fatalf("preview refused: allow=%v reason=%q err=%v", allow, reason, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Without a ticket the apply is answered with the plan it should have read.
|
||||
// Nothing reaches disk, and the caller gets exactly what it was missing.
|
||||
func TestApplyWithoutTicketInstallsNothing(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
writeHookedPlugin(t, src)
|
||||
home := t.TempDir()
|
||||
tl := NewTool(Options{ProjectRoot: t.TempDir(), HomeDir: home, RequireApprovedPlan: true})
|
||||
|
||||
resp := execInstall(t, tl, map[string]any{"source": src, "kind": "plugin", "apply": true})
|
||||
if resp.Applied || resp.Status != "planned" {
|
||||
t.Fatalf("response = %+v, want a plan and no write", resp)
|
||||
}
|
||||
if !strings.Contains(resp.Next, "planId") {
|
||||
t.Fatalf("next = %q, want it to name what the call was missing", resp.Next)
|
||||
}
|
||||
reasonixHome := filepath.Join(home, ".reasonix")
|
||||
if _, err := os.Stat(pluginpkg.InstallRoot(reasonixHome, "ticketed")); !os.IsNotExist(err) {
|
||||
t.Fatalf("an unticketed apply wrote an install root: %v", err)
|
||||
}
|
||||
|
||||
// The ticket it handed back is the one that works.
|
||||
done := execInstall(t, tl, map[string]any{
|
||||
"source": src, "kind": "plugin", "apply": true, "planId": resp.PlanID,
|
||||
})
|
||||
if !done.OK || done.Status != "done" {
|
||||
t.Fatalf("ticketed apply = %+v", done)
|
||||
}
|
||||
}
|
||||
|
||||
// The grade is not a label the caller gets to choose. A ticket that claims a
|
||||
// lower one is a different string than the plan hashes to, and the apply is
|
||||
// refused before anything is written.
|
||||
func TestDowngradedTicketIsRefused(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
writeHookedPlugin(t, src)
|
||||
home := t.TempDir()
|
||||
tl := NewTool(Options{ProjectRoot: t.TempDir(), HomeDir: home, RequireApprovedPlan: true})
|
||||
|
||||
resp := execInstall(t, tl, map[string]any{"source": src, "kind": "plugin"})
|
||||
forged := strings.Replace(resp.PlanID, "high:", "low:", 1)
|
||||
if forged == resp.PlanID {
|
||||
t.Fatalf("planId = %q, expected a high grade to rewrite", resp.PlanID)
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"source": src, "kind": "plugin", "apply": true, "planId": forged,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tl.Execute(t.Context(), raw); err == nil {
|
||||
t.Fatal("a downgraded ticket was accepted")
|
||||
}
|
||||
if _, err := os.Stat(pluginpkg.InstallRoot(filepath.Join(home, ".reasonix"), "ticketed")); !os.IsNotExist(err) {
|
||||
t.Fatalf("a refused apply still wrote an install root: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Hosts install on the user's own click, so they never gained a second phase
|
||||
// they would have had to invent a ticket for.
|
||||
func TestHostToolAppliesWithoutATicket(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
writeHookedPlugin(t, src)
|
||||
tl := NewTool(Options{ProjectRoot: t.TempDir(), HomeDir: t.TempDir()})
|
||||
resp := execInstall(t, tl, map[string]any{"source": src, "kind": "plugin", "apply": true})
|
||||
if !resp.OK || resp.Status != "done" {
|
||||
t.Fatalf("host apply = %+v", resp)
|
||||
}
|
||||
}
|
||||
@@ -551,8 +551,8 @@ func (t *installSourceTool) preparePluginSource(ctx context.Context, source, mod
|
||||
return root, commit, func() { _ = os.RemoveAll(tmp) }, nil
|
||||
}
|
||||
path := t.resolvePath(source)
|
||||
if mode == "link" {
|
||||
return path, "", func() {}, nil
|
||||
if looksLikeZip(path) {
|
||||
return t.preparePluginZip(path, mode)
|
||||
}
|
||||
return path, "", func() {}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package permission
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// InstallSourceIsPlanOnly reports an install_source call that only produces a
|
||||
// plan. The tool is a writer as a whole, but its preview phase writes nothing.
|
||||
// An uninstall is not a preview: it removes what is already installed, and it
|
||||
// is the one op with nothing to read first.
|
||||
func InstallSourceIsPlanOnly(args json.RawMessage) bool {
|
||||
var call struct {
|
||||
Op string `json:"op"`
|
||||
Apply bool `json:"apply"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &call); err != nil {
|
||||
return false
|
||||
}
|
||||
if call.Apply {
|
||||
return false
|
||||
}
|
||||
return call.Op == "" || call.Op == "install"
|
||||
}
|
||||
|
||||
// selfExtendHumanRisk is the install-plan grade no blanket allow covers. It
|
||||
// mirrors installsource.RiskHigh, which cannot be imported here without pulling
|
||||
// the install stack underneath the permission layer;
|
||||
// installsource's TestHighRiskPlanAsksEvenUnderBlanketAllow holds the two together.
|
||||
const selfExtendHumanRisk = "high:"
|
||||
|
||||
// subjectRequiresHuman reports a call that comes back to the user even when the
|
||||
// fallback mode allows everything. "Allow every write" is a statement about
|
||||
// this workspace's files, never permission for the agent to give itself a
|
||||
// resident process, a lifecycle hook, or an external server. An explicit allow
|
||||
// rule for that plan's ticket still wins — that is how the line gets moved.
|
||||
func subjectRequiresHuman(toolName, subject string) bool {
|
||||
return canonicalRuleTool(toolName) == "install_source" && strings.HasPrefix(subject, selfExtendHumanRisk)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package permission
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func ticketArgs(t *testing.T, planID string) json.RawMessage {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(map[string]any{"source": "x", "apply": true, "planId": planID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// The grade rides in front of the plan hash, so the fallback mode covers the
|
||||
// two lower ones and stops at the one that runs code the user did not write.
|
||||
func TestPlanTicketGradeDecidesWhatABlanketAllowCovers(t *testing.T) {
|
||||
blanket := New("allow", nil, nil, nil)
|
||||
for _, ticket := range []string{"low:sha256:abc", "medium:sha256:abc"} {
|
||||
if got := blanket.Decide("install_source", false, ticketArgs(t, ticket)); got != Allow {
|
||||
t.Errorf("Decide(%q) = %v, want Allow", ticket, got)
|
||||
}
|
||||
}
|
||||
if got := blanket.Decide("install_source", false, ticketArgs(t, "high:sha256:abc")); got != Ask {
|
||||
t.Errorf("Decide(high) = %v, want Ask even under a blanket allow", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Deny still wins: the escape hatch for a high ticket is an allow rule, never a
|
||||
// weaker posture, and nothing here can talk past an explicit refusal.
|
||||
func TestPlanTicketRulesKeepPrecedence(t *testing.T) {
|
||||
denied := New("allow", []string{"install_source(high:*)"}, nil, []string{"install_source"})
|
||||
if got := denied.Decide("install_source", false, ticketArgs(t, "high:sha256:abc")); got != Deny {
|
||||
t.Errorf("Decide with a deny rule = %v, want Deny", got)
|
||||
}
|
||||
asked := New("allow", nil, []string{"install_source(low:*)"}, nil)
|
||||
if got := asked.Decide("install_source", false, ticketArgs(t, "low:sha256:abc")); got != Ask {
|
||||
t.Errorf("Decide with an ask rule on a low ticket = %v, want Ask", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Uninstall carries no ticket and gets no special grade: it takes a capability
|
||||
// away rather than granting one, and the package it removes can be installed
|
||||
// again. That makes it an ordinary write, decided by the fallback mode.
|
||||
func TestUninstallIsAnOrdinaryWrite(t *testing.T) {
|
||||
raw, err := json.Marshal(map[string]any{"op": "uninstall", "name": "pack"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := New("allow", nil, nil, nil).Decide("install_source", false, raw); got != Allow {
|
||||
t.Errorf("uninstall under allow = %v, want Allow", got)
|
||||
}
|
||||
if got := New("ask", nil, nil, nil).Decide("install_source", false, raw); got != Ask {
|
||||
t.Errorf("uninstall under ask = %v, want Ask", got)
|
||||
}
|
||||
if InstallSourceIsPlanOnly(raw) {
|
||||
t.Error("uninstall was read as a preview; it has none")
|
||||
}
|
||||
}
|
||||
@@ -264,6 +264,8 @@ func (p Policy) DecideSubject(toolName string, readOnly bool, subject string) De
|
||||
return Allow
|
||||
case readOnly:
|
||||
return Allow
|
||||
case subjectRequiresHuman(toolName, subject):
|
||||
return Ask
|
||||
default:
|
||||
return p.Mode
|
||||
}
|
||||
@@ -572,92 +574,6 @@ func bashRulePrefixBaseMatches(existing, candidate Rule) bool {
|
||||
return ok && existingBase == candidateBase
|
||||
}
|
||||
|
||||
// subjectKeys are the JSON argument keys, in priority order, that carry a tool
|
||||
// call's "subject" — the thing a Subject glob matches against. Generic so tools
|
||||
// need not implement a permission-specific method: bash exposes command, the
|
||||
// file tools expose path / file_path, grep & glob expose pattern.
|
||||
var subjectKeys = []string{"command", "file_path", "path", "source_path", "destination_path", "pattern"}
|
||||
|
||||
// Subject extracts the primary matchable subject string from a call's raw JSON
|
||||
// args, returning "" when none of the known keys is present (such a call only
|
||||
// matches bare "ToolName" rules). Use Subjects for permission decisions that
|
||||
// must account for every touched endpoint.
|
||||
func Subject(args json.RawMessage) string {
|
||||
subjects := Subjects(args)
|
||||
if len(subjects) > 0 {
|
||||
return subjects[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Subjects extracts every matchable subject from a call's raw JSON args. Most
|
||||
// tools expose one subject; move_file exposes both source_path and
|
||||
// destination_path so path-scoped permission rules can protect either endpoint.
|
||||
func Subjects(args json.RawMessage) []string {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(args, &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
src := stringArg(m, "source_path")
|
||||
dst := stringArg(m, "destination_path")
|
||||
if src != "" && dst != "" {
|
||||
out := []string{src}
|
||||
if dst != src {
|
||||
out = append(out, dst)
|
||||
}
|
||||
return out
|
||||
}
|
||||
for _, k := range subjectKeys {
|
||||
if s := stringArg(m, k); s != "" {
|
||||
return []string{s}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringArg(m map[string]any, key string) string {
|
||||
if v, ok := m[key]; ok {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// matchGlob reports whether name matches pattern, where '*' matches any run of
|
||||
// characters (including separators) and '?' matches exactly one. Unlike
|
||||
// path.Match, '*' is not stopped by '/', which is what command-line and path
|
||||
// prefixes ("rm -rf*", "/etc/*") intuitively expect. Linear time with
|
||||
// backtracking, byte-oriented.
|
||||
func matchGlob(pattern, name string) bool {
|
||||
var px, nx, starPx, starNx int
|
||||
starPx = -1
|
||||
for nx < len(name) {
|
||||
switch {
|
||||
case px < len(pattern) && pattern[px] == '*':
|
||||
starPx = px
|
||||
starNx = nx
|
||||
px++
|
||||
case px < len(pattern) && (pattern[px] == '?' || pattern[px] == name[nx]):
|
||||
px++
|
||||
nx++
|
||||
case starPx != -1:
|
||||
px = starPx + 1
|
||||
starNx++
|
||||
nx = starNx
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
for px < len(pattern) && pattern[px] == '*' {
|
||||
px++
|
||||
}
|
||||
return px == len(pattern)
|
||||
}
|
||||
|
||||
// Approver resolves an Ask decision interactively. Implementations live in the
|
||||
// front-end (the chat TUI); a non-interactive run passes a nil Approver, which
|
||||
// the Gate treats as "allow" to preserve autonomous behaviour.
|
||||
@@ -704,6 +620,12 @@ func (g *Gate) Check(ctx context.Context, toolName string, args json.RawMessage,
|
||||
readOnly = true
|
||||
}
|
||||
}
|
||||
// Producing an install plan reads the source and writes nothing. Treating
|
||||
// the preview as the write it precedes would train the user to approve the
|
||||
// question that carries no information, ahead of the one that does.
|
||||
if toolName == "install_source" && !readOnly && InstallSourceIsPlanOnly(args) {
|
||||
readOnly = true
|
||||
}
|
||||
decision := g.Policy.Decide(toolName, readOnly, args)
|
||||
ruleReason := ""
|
||||
if rule, ok := g.Policy.MatchedRule(toolName, decision, args); ok {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// subject.go — what a permission rule matches against: the one string in a
|
||||
// tool call that names what it will touch, and the glob that tests it.
|
||||
package permission
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// subjectKeys are the JSON argument keys, in priority order, that carry a tool
|
||||
// call's "subject" — the thing a Subject glob matches against. Generic so tools
|
||||
// need not implement a permission-specific method: bash exposes command, the
|
||||
// file tools expose path / file_path, grep & glob expose pattern. planId leads
|
||||
// because it is a ticket rather than a target — see subjectRequiresHuman.
|
||||
var subjectKeys = []string{"planId", "command", "file_path", "path", "source_path", "destination_path", "pattern"}
|
||||
|
||||
// Subject extracts the primary matchable subject string from a call's raw JSON
|
||||
// args, returning "" when none of the known keys is present (such a call only
|
||||
// matches bare "ToolName" rules). Use Subjects for permission decisions that
|
||||
// must account for every touched endpoint.
|
||||
func Subject(args json.RawMessage) string {
|
||||
subjects := Subjects(args)
|
||||
if len(subjects) > 0 {
|
||||
return subjects[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Subjects extracts every matchable subject from a call's raw JSON args. Most
|
||||
// tools expose one subject; move_file exposes both source_path and
|
||||
// destination_path so path-scoped permission rules can protect either endpoint.
|
||||
func Subjects(args json.RawMessage) []string {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(args, &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
src := stringArg(m, "source_path")
|
||||
dst := stringArg(m, "destination_path")
|
||||
if src != "" && dst != "" {
|
||||
out := []string{src}
|
||||
if dst != src {
|
||||
out = append(out, dst)
|
||||
}
|
||||
return out
|
||||
}
|
||||
for _, k := range subjectKeys {
|
||||
if s := stringArg(m, k); s != "" {
|
||||
return []string{s}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringArg(m map[string]any, key string) string {
|
||||
if v, ok := m[key]; ok {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// matchGlob reports whether name matches pattern, where '*' matches any run of
|
||||
// characters (including separators) and '?' matches exactly one. Unlike
|
||||
// path.Match, '*' is not stopped by '/', which is what command-line and path
|
||||
// prefixes ("rm -rf*", "/etc/*") intuitively expect. Linear time with
|
||||
// backtracking, byte-oriented.
|
||||
func matchGlob(pattern, name string) bool {
|
||||
var px, nx, starPx, starNx int
|
||||
starPx = -1
|
||||
for nx < len(name) {
|
||||
switch {
|
||||
case px < len(pattern) && pattern[px] == '*':
|
||||
starPx = px
|
||||
starNx = nx
|
||||
px++
|
||||
case px < len(pattern) && (pattern[px] == '?' || pattern[px] == name[nx]):
|
||||
px++
|
||||
nx++
|
||||
case starPx != -1:
|
||||
px = starPx + 1
|
||||
starNx++
|
||||
nx = starNx
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
for px < len(pattern) && pattern[px] == '*' {
|
||||
px++
|
||||
}
|
||||
return px == len(pattern)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// export.go — an installed package, packed for someone else's machine.
|
||||
package pluginpkg
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExportSizeLimit caps the packed bytes. A plugin root is arbitrary user
|
||||
// content — a vendored node_modules or a checked-in model file turns an export
|
||||
// into a memory incident — so the archive is refused rather than truncated.
|
||||
const ExportSizeLimit = 64 << 20
|
||||
|
||||
// exportSkipDirs never travel with a package. .git in particular carries
|
||||
// remote URLs with embedded tokens, which is exactly what an export must not
|
||||
// hand to a stranger.
|
||||
var exportSkipDirs = map[string]bool{".git": true, ".hg": true, ".svn": true}
|
||||
|
||||
var envVarRef = regexp.MustCompile(`^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$`)
|
||||
|
||||
// Export packs the package rooted at root into a zip whose entries hang off a
|
||||
// single <name>/ directory, and reports the environment variables whoever
|
||||
// installs it has to supply. Every literal value in its MCP and runtime
|
||||
// configuration is replaced by an ${ENV_VAR} reference on the way out.
|
||||
func Export(name, root string) ([]byte, []string, error) {
|
||||
if !IsValidName(name) {
|
||||
return nil, nil, fmt.Errorf("invalid plugin name %q", name)
|
||||
}
|
||||
// A linked install is a symlink to wherever the author keeps it; walking
|
||||
// the link itself would pack nothing at all.
|
||||
resolved, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
required := map[string]bool{}
|
||||
var total int64
|
||||
|
||||
walkErr := filepath.WalkDir(resolved, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(resolved, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
if exportSkipDirs[d.Name()] {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Only regular files: a symlink out of the package would either
|
||||
// dangle on the other machine or smuggle a path off this one.
|
||||
if !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if credentialBearing(rel) {
|
||||
stripped, names, err := StripCredentials(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", rel, err)
|
||||
}
|
||||
data = stripped
|
||||
for _, n := range names {
|
||||
required[n] = true
|
||||
}
|
||||
}
|
||||
total += int64(len(data))
|
||||
if total > ExportSizeLimit {
|
||||
return fmt.Errorf("plugin %q exceeds the %d MB export limit", name, ExportSizeLimit>>20)
|
||||
}
|
||||
w, err := zw.Create(path.Join(name, rel))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
})
|
||||
if walkErr != nil {
|
||||
return nil, nil, walkErr
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
out := make([]string, 0, len(required))
|
||||
for n := range required {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return buf.Bytes(), out, nil
|
||||
}
|
||||
|
||||
// credentialBearing reports whether a file inside a package can carry MCP or
|
||||
// runtime configuration. Every other file travels byte-for-byte: rewriting a
|
||||
// skill's own JSON fixture because it happens to have an "env" key would
|
||||
// corrupt the package to protect nothing.
|
||||
func credentialBearing(rel string) bool {
|
||||
switch rel {
|
||||
case NativeManifest, CodexManifest, ClaudeManifest, claudeMCPPath, claudeSettingsPath:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// StripCredentials rewrites every literal value under an "env" or "headers"
|
||||
// object into an ${ENV_VAR} reference and reports the variable names the result
|
||||
// now depends on. Telling secrets from ordinary values is deliberately not
|
||||
// attempted: an export leaves this machine, so a heuristic that misses one
|
||||
// leaks it, while a false positive costs one variable to fill in.
|
||||
func StripCredentials(raw []byte) ([]byte, []string, error) {
|
||||
var doc any
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
required := map[string]bool{}
|
||||
stripNode(doc, "", required)
|
||||
// Re-serialized, because the rewrite has to reach the file: field order
|
||||
// follows Go's map ordering rather than the author's.
|
||||
out, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
names := make([]string, 0, len(required))
|
||||
for n := range required {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return append(out, '\n'), names, nil
|
||||
}
|
||||
|
||||
// stripNode walks the document carrying the name of the server it is inside,
|
||||
// so two servers that both authenticate do not collapse onto one variable.
|
||||
func stripNode(node any, scope string, required map[string]bool) {
|
||||
obj, ok := node.(map[string]any)
|
||||
if !ok {
|
||||
if list, ok := node.([]any); ok {
|
||||
for _, item := range list {
|
||||
stripNode(item, scope, required)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
for key, value := range obj {
|
||||
switch key {
|
||||
case "mcpServers":
|
||||
servers, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for serverName, server := range servers {
|
||||
stripNode(server, serverName, required)
|
||||
}
|
||||
case "env", "headers":
|
||||
fields, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for field, fieldValue := range fields {
|
||||
text, ok := fieldValue.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name := exportVarName(scope, field)
|
||||
if ref := envVarRef.FindStringSubmatch(strings.TrimSpace(text)); ref != nil {
|
||||
required[ref[1]] = true
|
||||
continue
|
||||
}
|
||||
fields[field] = "${" + name + "}"
|
||||
required[name] = true
|
||||
}
|
||||
default:
|
||||
stripNode(value, scope, required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func exportVarName(scope, field string) string {
|
||||
name := envVarName(scope + "_" + field)
|
||||
if strings.TrimSpace(scope) == "" {
|
||||
name = envVarName(field)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func envVarName(key string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToUpper(strings.TrimSpace(key)) {
|
||||
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
if name := strings.Trim(b.String(), "_"); name != "" {
|
||||
return name
|
||||
}
|
||||
return "SECRET"
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package pluginpkg
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeExportFile(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func exportedEntries(t *testing.T, archive []byte) map[string]string {
|
||||
t.Helper()
|
||||
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, f := range zr.File {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if _, err := buf.ReadFrom(rc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc.Close()
|
||||
out[f.Name] = buf.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestExportStripsCredentialsAndNamesThemPerServer(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeExportFile(t, filepath.Join(root, NativeManifest), `{
|
||||
"apiVersion": "reasonix.io/plugin/v2",
|
||||
"name": "demo",
|
||||
"mcpServers": {
|
||||
"docs": {"command": "docs", "env": {"TOKEN": "sk-live-123"}},
|
||||
"wiki": {"url": "https://wiki.example", "headers": {"Authorization": "Bearer abc"}}
|
||||
},
|
||||
"runtime": {"command": "bin/rt", "env": {"SEAT": "${SEAT_ID}"}}
|
||||
}`)
|
||||
writeExportFile(t, filepath.Join(root, ".git", "config"), "[remote]\n url = https://x:tok@example\n")
|
||||
writeExportFile(t, filepath.Join(root, "skills", "greet", "fixture.json"), `{"env":{"KEEP":"me"}}`)
|
||||
|
||||
archive, required, err := Export("demo", root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries := exportedEntries(t, archive)
|
||||
|
||||
if _, ok := entries["demo/.git/config"]; ok {
|
||||
t.Fatal("export packed .git, whose remote URLs carry tokens")
|
||||
}
|
||||
// Only configuration is rewritten; a skill's own data file is content.
|
||||
if got := entries["demo/skills/greet/fixture.json"]; got != `{"env":{"KEEP":"me"}}` {
|
||||
t.Fatalf("fixture.json = %q, want it byte-for-byte", got)
|
||||
}
|
||||
|
||||
manifest := entries["demo/"+NativeManifest]
|
||||
for _, literal := range []string{"sk-live-123", "Bearer abc"} {
|
||||
if strings.Contains(manifest, literal) {
|
||||
t.Fatalf("exported manifest still carries %q:\n%s", literal, manifest)
|
||||
}
|
||||
}
|
||||
var doc struct {
|
||||
MCPServers map[string]struct {
|
||||
Env map[string]string `json:"env"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
} `json:"mcpServers"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(manifest), &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Two servers that both authenticate must not collapse onto one variable.
|
||||
if got := doc.MCPServers["docs"].Env["TOKEN"]; got != "${DOCS_TOKEN}" {
|
||||
t.Fatalf("docs env TOKEN = %q", got)
|
||||
}
|
||||
if got := doc.MCPServers["wiki"].Headers["Authorization"]; got != "${WIKI_AUTHORIZATION}" {
|
||||
t.Fatalf("wiki Authorization = %q", got)
|
||||
}
|
||||
want := []string{"DOCS_TOKEN", "SEAT_ID", "WIKI_AUTHORIZATION"}
|
||||
if strings.Join(required, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("required = %v, want %v", required, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A reference was never a literal, so it survives unchanged — but whoever
|
||||
// installs the package still has to provide it.
|
||||
func TestStripCredentialsKeepsExistingReferences(t *testing.T) {
|
||||
out, names, err := StripCredentials([]byte(`{"mcpServers":{"a":{"env":{"K":"${EXISTING}"}}}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(out), "${EXISTING}") {
|
||||
t.Fatalf("rewrote an existing reference: %s", out)
|
||||
}
|
||||
if len(names) != 1 || names[0] != "EXISTING" {
|
||||
t.Fatalf("required = %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportRefusesInvalidName(t *testing.T) {
|
||||
if _, _, err := Export("../etc", t.TempDir()); err == nil {
|
||||
t.Fatal("Export accepted a path-shaped name")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
// plugins.go — installed plugin packages: what each one contributes, and the
|
||||
// two-phase install that puts one there.
|
||||
package serve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/hook"
|
||||
"reasonix/internal/installsource"
|
||||
"reasonix/internal/pluginpkg"
|
||||
)
|
||||
|
||||
// A package's capabilities are assembled at boot, so every write here ends
|
||||
// with a runtime reload. A reload refused mid-turn is reported next to the
|
||||
// result instead of as a failure: the state on disk did change.
|
||||
func (s *Server) registerPluginRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /plugins", s.plugins)
|
||||
mux.HandleFunc("POST /plugins/plan", s.planPlugin)
|
||||
mux.HandleFunc("POST /plugins/install", s.installPlugin)
|
||||
mux.HandleFunc("POST /plugins/enabled", s.pluginEnabled)
|
||||
mux.HandleFunc("DELETE /plugins/{name}", s.removePlugin)
|
||||
mux.HandleFunc("GET /plugins/{name}/export", s.exportPlugin)
|
||||
}
|
||||
|
||||
type pluginView struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Root string `json:"root"`
|
||||
ManifestKind string `json:"manifestKind,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Status string `json:"status,omitempty"`
|
||||
StatusReason string `json:"statusReason,omitempty"`
|
||||
Compatibility string `json:"compatibility,omitempty"`
|
||||
Skipped []pluginpkg.CompatibilityIssue `json:"skipped,omitempty"`
|
||||
Skills []pluginItem `json:"skills,omitempty"`
|
||||
Agents []pluginItem `json:"agents,omitempty"`
|
||||
Commands []pluginItem `json:"commands,omitempty"`
|
||||
Prompts []pluginItem `json:"prompts,omitempty"`
|
||||
Themes []pluginItem `json:"themes,omitempty"`
|
||||
// Hooks, servers and a runtime execute code the package brought with it.
|
||||
// They stay out of the contribution list a reader skims, because "one more
|
||||
// skill" and "one more process" are not the same size of decision.
|
||||
Hooks []pluginHook `json:"hooks,omitempty"`
|
||||
MCPServers []pluginServer `json:"mcpServers,omitempty"`
|
||||
Runtime *pluginRuntime `json:"runtime,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type pluginItem struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Invocation string `json:"invocation,omitempty"`
|
||||
}
|
||||
|
||||
type pluginHook struct {
|
||||
Event string `json:"event"`
|
||||
Match string `json:"match,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
ContextFile string `json:"contextFile,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type pluginServer struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
AutoStart bool `json:"autoStart,omitempty"`
|
||||
}
|
||||
|
||||
// A runtime process runs inside Reasonix with the user's full trust, so what
|
||||
// it intercepts and replaces is part of the row rather than a detail behind it.
|
||||
type pluginRuntime struct {
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Intercepts []string `json:"intercepts,omitempty"`
|
||||
Replaces []string `json:"replaces,omitempty"`
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) plugins(w http.ResponseWriter, r *http.Request) {
|
||||
home := config.ReasonixHomeDir()
|
||||
st, err := pluginpkg.LoadState(home)
|
||||
if err != nil {
|
||||
writeJSONStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
root := s.ctl().WorkspaceRoot()
|
||||
out := make([]pluginView, 0, len(st.Plugins))
|
||||
for _, p := range st.Plugins {
|
||||
out = append(out, pluginViewFor(home, root, p))
|
||||
}
|
||||
writeJSON(w, out)
|
||||
}
|
||||
|
||||
// A package whose directory no longer parses still gets a row: it is installed,
|
||||
// it is what the user came here to fix, and dropping it would make the list
|
||||
// disagree with the state file.
|
||||
func pluginViewFor(home, workspaceRoot string, p pluginpkg.InstalledPlugin) pluginView {
|
||||
view := pluginView{
|
||||
Name: p.Name, Version: p.Version, Description: p.Description,
|
||||
Source: p.Source, Root: pluginpkg.ResolveRoot(home, p.Root),
|
||||
ManifestKind: p.ManifestKind, Enabled: p.Enabled,
|
||||
Status: p.Status, StatusReason: p.StatusReason,
|
||||
}
|
||||
pkg, warnings, err := pluginpkg.ParseDir(view.Root)
|
||||
if err != nil {
|
||||
view.Error = err.Error()
|
||||
return view
|
||||
}
|
||||
view.Warnings = warnings
|
||||
view.Compatibility = pkg.Compatibility.Status
|
||||
view.Skipped = pkg.Compatibility.Skipped
|
||||
|
||||
inv := pkg.Inventory()
|
||||
for _, sk := range inv.Skills {
|
||||
view.Skills = append(view.Skills, pluginItem{
|
||||
Name: sk.Name, Description: sk.Description, Invocation: "/" + p.Name + ":" + sk.Name,
|
||||
})
|
||||
}
|
||||
for _, a := range inv.Agents {
|
||||
view.Agents = append(view.Agents, pluginItem{
|
||||
Name: a.Name, Description: a.Description, Invocation: "/" + p.Name + ":agent:" + a.Name,
|
||||
})
|
||||
}
|
||||
for _, c := range inv.Commands {
|
||||
view.Commands = append(view.Commands, pluginItem{
|
||||
Name: c.Name, Description: c.Description, Invocation: "/" + p.Name + ":" + c.Name,
|
||||
})
|
||||
}
|
||||
for _, pr := range inv.Prompts {
|
||||
view.Prompts = append(view.Prompts, pluginItem{Name: pr.Name, Description: pr.Description})
|
||||
}
|
||||
for _, th := range inv.Themes {
|
||||
view.Themes = append(view.Themes, pluginItem{Name: th.Name})
|
||||
}
|
||||
for _, h := range inv.Hooks {
|
||||
view.Hooks = append(view.Hooks, pluginHook{
|
||||
Event: h.Event, Match: h.Match, Command: h.Command,
|
||||
ContextFile: h.ContextFile, Description: h.Description,
|
||||
})
|
||||
}
|
||||
for _, srv := range inv.MCPServers {
|
||||
view.MCPServers = append(view.MCPServers, pluginServer{
|
||||
Name: srv.Name, DisplayName: srv.DisplayName, Description: srv.Description,
|
||||
Transport: srv.Transport, Command: srv.Command, URL: srv.URL, AutoStart: srv.AutoStart,
|
||||
})
|
||||
}
|
||||
if rt := pkg.Manifest.Runtime; rt != nil {
|
||||
view.Runtime = &pluginRuntime{
|
||||
Command: rt.Command, Args: rt.Args, Intercepts: rt.Intercepts,
|
||||
Replaces: rt.Replaces, Capabilities: rt.Capabilities,
|
||||
}
|
||||
}
|
||||
view.Warnings = append(view.Warnings, hookRuntimeWarnings(pkg, workspaceRoot)...)
|
||||
return view
|
||||
}
|
||||
|
||||
// hookRuntimeWarnings reports the package's hooks that cannot run on this
|
||||
// machine — a bash hook on a Windows box without Git Bash is the usual one.
|
||||
// It rides on the listing rather than behind a "diagnose" button: a hook that
|
||||
// silently never fires is exactly what the user came to this page to find out.
|
||||
func hookRuntimeWarnings(pkg pluginpkg.Package, workspaceRoot string) []string {
|
||||
if len(pkg.Manifest.Hooks) == 0 {
|
||||
return nil
|
||||
}
|
||||
options := hook.RuntimeOptions{}
|
||||
if cfg, _ := config.LoadForRootReadOnly(workspaceRoot); cfg != nil {
|
||||
options = hook.RuntimeOptionsForShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path)
|
||||
}
|
||||
var out []string
|
||||
for _, issue := range hook.CheckPackageRuntime(pkg, options) {
|
||||
out = append(out, fmt.Sprintf("%s 钩子跑不起来:%v", issue.Event, issue.Err))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type pluginInstallRequest struct {
|
||||
Source string `json:"source"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Link bool `json:"link,omitempty"`
|
||||
Replace bool `json:"replace,omitempty"`
|
||||
// PlanID echoes the plan the user approved. install_source refuses an apply
|
||||
// whose plan hashes differently, which is what stops a source from
|
||||
// describing one thing and installing another.
|
||||
PlanID string `json:"planId,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) planPlugin(w http.ResponseWriter, r *http.Request) {
|
||||
req, ok := decodePluginInstall(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.answerInstallSource(r.Context(), w, pluginInstallBody(req, false), false)
|
||||
}
|
||||
|
||||
func (s *Server) installPlugin(w http.ResponseWriter, r *http.Request) {
|
||||
req, ok := decodePluginInstall(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.answerInstallSource(r.Context(), w, pluginInstallBody(req, true), true)
|
||||
}
|
||||
|
||||
func (s *Server) removePlugin(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimSpace(r.PathValue("name"))
|
||||
if !pluginpkg.IsValidName(name) {
|
||||
http.Error(w, "invalid plugin name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
body := map[string]any{"op": "uninstall", "kind": "plugin", "name": name, "scope": "global"}
|
||||
s.answerInstallSource(r.Context(), w, body, true)
|
||||
}
|
||||
|
||||
func (s *Server) pluginEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := pluginpkg.SetEnabled(config.ReasonixHomeDir(), name, req.Enabled); err != nil {
|
||||
writeJSONStatus(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := map[string]any{"name": name, "enabled": req.Enabled}
|
||||
if err := s.reloadExtensions(r.Context()); err != nil {
|
||||
out["reloadError"] = err.Error()
|
||||
}
|
||||
writeJSON(w, out)
|
||||
}
|
||||
|
||||
// exportPlugin answers with the package as a zip. What it strips on the way
|
||||
// out — the literal values of its MCP and runtime configuration — is named in
|
||||
// a header, because a download body has nowhere to say it and the person
|
||||
// receiving the archive has to be told what they will need to fill in.
|
||||
func (s *Server) exportPlugin(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimSpace(r.PathValue("name"))
|
||||
if !pluginpkg.IsValidName(name) {
|
||||
http.Error(w, "invalid plugin name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
home := config.ReasonixHomeDir()
|
||||
st, err := pluginpkg.LoadState(home)
|
||||
if err != nil {
|
||||
writeJSONStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
root := ""
|
||||
for _, p := range st.Plugins {
|
||||
if p.Name == name {
|
||||
root = pluginpkg.ResolveRoot(home, p.Root)
|
||||
break
|
||||
}
|
||||
}
|
||||
if root == "" {
|
||||
http.Error(w, "plugin is not installed", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
archive, required, err := pluginpkg.Export(name, root)
|
||||
if err != nil {
|
||||
writeJSONStatus(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`.zip"`)
|
||||
if len(required) > 0 {
|
||||
w.Header().Set("X-Reasonix-Required-Env", strings.Join(required, ","))
|
||||
}
|
||||
_, _ = w.Write(archive)
|
||||
}
|
||||
|
||||
func decodePluginInstall(w http.ResponseWriter, r *http.Request) (pluginInstallRequest, bool) {
|
||||
var req pluginInstallRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return req, false
|
||||
}
|
||||
req.Source = strings.TrimSpace(req.Source)
|
||||
if req.Source == "" {
|
||||
http.Error(w, "source required", http.StatusBadRequest)
|
||||
return req, false
|
||||
}
|
||||
return req, true
|
||||
}
|
||||
|
||||
func pluginInstallBody(req pluginInstallRequest, apply bool) map[string]any {
|
||||
mode := "copy"
|
||||
if req.Link {
|
||||
mode = "link"
|
||||
}
|
||||
body := map[string]any{
|
||||
"source": req.Source,
|
||||
"kind": "plugin",
|
||||
"mode": mode,
|
||||
"replace": req.Replace,
|
||||
"apply": apply,
|
||||
}
|
||||
if name := strings.TrimSpace(req.Name); name != "" {
|
||||
body["name"] = name
|
||||
}
|
||||
if planID := strings.TrimSpace(req.PlanID); apply && planID != "" {
|
||||
body["planId"] = planID
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// answerInstallSource forwards the tool's own JSON. Its plan is already the
|
||||
// structured answer a confirmation page renders, so re-shaping it here would
|
||||
// only add a second place for the two to disagree. A tool error is a request
|
||||
// the server could not carry out at all; everything the plan itself reports —
|
||||
// blocked, denied, partial — rides in the body with its reasons intact.
|
||||
func (s *Server) answerInstallSource(ctx context.Context, w http.ResponseWriter, body map[string]any, mayWrite bool) {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
writeJSONStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
tool := installsource.NewTool(installsource.Options{
|
||||
ProjectRoot: s.ctl().WorkspaceRoot(),
|
||||
// Uninstall drops the package's MCP servers out of the live session
|
||||
// too; without this they keep answering until something rebuilds.
|
||||
OnDisconnect: s.ctl().DisconnectMCPServer,
|
||||
})
|
||||
out, err := tool.Execute(ctx, raw)
|
||||
if err != nil {
|
||||
writeJSONStatus(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(out), &fields); err != nil {
|
||||
writeJSONStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// A plan that wrote nothing has nothing to reload for, and a refused reload
|
||||
// would read as the install itself having failed.
|
||||
if mayWrite && jsonTrue(fields["applied"]) {
|
||||
if err := s.reloadExtensions(ctx); err != nil {
|
||||
fields["reloadError"], _ = json.Marshal(err.Error())
|
||||
}
|
||||
}
|
||||
writeJSON(w, fields)
|
||||
}
|
||||
|
||||
func jsonTrue(raw json.RawMessage) bool {
|
||||
var v bool
|
||||
return json.Unmarshal(raw, &v) == nil && v
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/pluginpkg"
|
||||
)
|
||||
|
||||
// pluginCtl is the controller half these routes touch: where a source path is
|
||||
// resolved from, and the live-session disconnect an uninstall calls.
|
||||
type pluginCtl struct {
|
||||
control.SessionAPI
|
||||
root string
|
||||
disconnected []string
|
||||
}
|
||||
|
||||
func (c *pluginCtl) SessionDir() string { return "" }
|
||||
func (c *pluginCtl) WorkspaceRoot() string { return c.root }
|
||||
|
||||
// Idle: every write here asks for a reload afterwards, and the reload refuses
|
||||
// outright while a turn is running.
|
||||
func (c *pluginCtl) RuntimeStatus() control.RuntimeStatus { return control.RuntimeStatus{} }
|
||||
|
||||
func (c *pluginCtl) DisconnectMCPServer(name string) bool {
|
||||
c.disconnected = append(c.disconnected, name)
|
||||
return true
|
||||
}
|
||||
|
||||
// pluginHome isolates the state file and install root, and returns a server
|
||||
// bound to a controller whose workspace is a throwaway directory.
|
||||
func pluginHome(t *testing.T) (string, *pluginCtl, string) {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
ctl := &pluginCtl{root: t.TempDir()}
|
||||
srv := httptest.NewServer(New(ctl, NewBroadcaster(), config.ServeConfig{}).Handler())
|
||||
t.Cleanup(srv.Close)
|
||||
return home, ctl, srv.URL
|
||||
}
|
||||
|
||||
// writePluginSource lays out a package that contributes one of everything the
|
||||
// list has to separate: a skill, a hook, and an MCP server.
|
||||
func writePluginSource(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
writePluginFile(t, filepath.Join(root, "reasonix-plugin.json"), `{
|
||||
"apiVersion": "reasonix.io/plugin/v2",
|
||||
"name": "demo",
|
||||
"version": "0.2.0",
|
||||
"description": "a demo package",
|
||||
"skills": ["skills"],
|
||||
"hooks": {"SessionStart": [{"command": "hooks/start.sh"}]},
|
||||
"mcpServers": {"docs": {"command": "docs-server", "args": ["--stdio"]}}
|
||||
}`)
|
||||
writePluginFile(t, filepath.Join(root, "skills", "greet", "SKILL.md"),
|
||||
"---\nname: greet\ndescription: say hello\n---\nHello")
|
||||
writePluginFile(t, filepath.Join(root, "hooks", "start.sh"), "#!/bin/sh\n")
|
||||
}
|
||||
|
||||
func writePluginFile(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func getPlugins(t *testing.T, base string) []pluginView {
|
||||
t.Helper()
|
||||
resp, err := http.Get(base + "/plugins")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET /plugins = %d", resp.StatusCode)
|
||||
}
|
||||
var out []pluginView
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decodeInstallSource(t *testing.T, resp *http.Response) map[string]any {
|
||||
t.Helper()
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestPluginPlanWritesNothing(t *testing.T) {
|
||||
home, _, base := pluginHome(t)
|
||||
src := t.TempDir()
|
||||
writePluginSource(t, src)
|
||||
|
||||
resp := postJSON(t, base+"/plugins/plan", map[string]any{"source": src})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("POST /plugins/plan = %d: %v", resp.StatusCode, decodeInstallSource(t, resp))
|
||||
}
|
||||
body := decodeInstallSource(t, resp)
|
||||
if body["status"] != "planned" {
|
||||
t.Fatalf("status = %v, want planned", body["status"])
|
||||
}
|
||||
if body["planId"] == "" || body["planId"] == nil {
|
||||
t.Fatal("a plan must carry the id an apply echoes back")
|
||||
}
|
||||
actions, _ := body["actions"].([]any)
|
||||
if len(actions) != 1 {
|
||||
t.Fatalf("actions = %v, want one plugin action", body["actions"])
|
||||
}
|
||||
// Risk is what the confirmation page groups by, so it has to survive the
|
||||
// hop through this endpoint rather than being summarized away.
|
||||
act, _ := actions[0].(map[string]any)
|
||||
if act["riskLevel"] == nil || act["riskLevel"] == "" {
|
||||
t.Fatalf("action = %v, want a risk level", act)
|
||||
}
|
||||
if _, err := os.Stat(pluginpkg.InstallRoot(home, "demo")); !os.IsNotExist(err) {
|
||||
t.Fatalf("plan wrote an install root: %v", err)
|
||||
}
|
||||
if got := getPlugins(t, base); len(got) != 0 {
|
||||
t.Fatalf("plugins after plan = %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginInstallListsItsContributions(t *testing.T) {
|
||||
_, _, base := pluginHome(t)
|
||||
src := t.TempDir()
|
||||
writePluginSource(t, src)
|
||||
|
||||
resp := postJSON(t, base+"/plugins/install", map[string]any{"source": src})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("POST /plugins/install = %d: %v", resp.StatusCode, decodeInstallSource(t, resp))
|
||||
}
|
||||
body := decodeInstallSource(t, resp)
|
||||
if body["status"] != "done" {
|
||||
t.Fatalf("status = %v, want done: %v", body["status"], body)
|
||||
}
|
||||
|
||||
list := getPlugins(t, base)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("plugins = %v, want one", list)
|
||||
}
|
||||
p := list[0]
|
||||
if p.Name != "demo" || p.Version != "0.2.0" || !p.Enabled {
|
||||
t.Fatalf("installed plugin = %+v", p)
|
||||
}
|
||||
if len(p.Skills) != 1 || p.Skills[0].Invocation != "/demo:greet" {
|
||||
t.Fatalf("skills = %+v, want the qualified invocation", p.Skills)
|
||||
}
|
||||
// Hooks and servers are the two the page has to be able to single out.
|
||||
if len(p.Hooks) != 1 || p.Hooks[0].Event != "SessionStart" {
|
||||
t.Fatalf("hooks = %+v", p.Hooks)
|
||||
}
|
||||
if len(p.MCPServers) != 1 || p.MCPServers[0].Name != "docs" {
|
||||
t.Fatalf("mcpServers = %+v", p.MCPServers)
|
||||
}
|
||||
}
|
||||
|
||||
// Updating is installing again with the recorded source and replace set, which
|
||||
// is why there is no third endpoint. What it must not do is leave two rows.
|
||||
func TestPluginUpdateReplacesInPlace(t *testing.T) {
|
||||
_, _, base := pluginHome(t)
|
||||
src := t.TempDir()
|
||||
writePluginSource(t, src)
|
||||
postJSON(t, base+"/plugins/install", map[string]any{"source": src})
|
||||
|
||||
writePluginFile(t, filepath.Join(src, "reasonix-plugin.json"), `{
|
||||
"apiVersion": "reasonix.io/plugin/v2",
|
||||
"name": "demo",
|
||||
"version": "0.3.0",
|
||||
"skills": ["skills"],
|
||||
"hooks": {"SessionStart": [{"command": "hooks/start.sh"}]},
|
||||
"mcpServers": {"docs": {"command": "docs-server", "args": ["--stdio"]}}
|
||||
}`)
|
||||
resp := postJSON(t, base+"/plugins/install", map[string]any{
|
||||
"source": src, "name": "demo", "replace": true,
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("update = %d: %v", resp.StatusCode, decodeInstallSource(t, resp))
|
||||
}
|
||||
if body := decodeInstallSource(t, resp); body["status"] != "done" {
|
||||
t.Fatalf("status = %v, want done: %v", body["status"], body)
|
||||
}
|
||||
list := getPlugins(t, base)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("plugins after update = %+v, want one row", list)
|
||||
}
|
||||
if list[0].Version != "0.3.0" {
|
||||
t.Fatalf("version = %q, want the new one", list[0].Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginEnabledPersists(t *testing.T) {
|
||||
home, _, base := pluginHome(t)
|
||||
src := t.TempDir()
|
||||
writePluginSource(t, src)
|
||||
postJSON(t, base+"/plugins/install", map[string]any{"source": src})
|
||||
|
||||
resp := postJSON(t, base+"/plugins/enabled", map[string]any{"name": "demo", "enabled": false})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("POST /plugins/enabled = %d: %v", resp.StatusCode, decodeInstallSource(t, resp))
|
||||
}
|
||||
st, err := pluginpkg.LoadState(home)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(st.Plugins) != 1 || st.Plugins[0].Enabled {
|
||||
t.Fatalf("state = %+v, want demo disabled on disk", st.Plugins)
|
||||
}
|
||||
if list := getPlugins(t, base); len(list) != 1 || list[0].Enabled {
|
||||
t.Fatalf("plugins = %+v, want the switch reflected", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginEnabledRefusesUnknownName(t *testing.T) {
|
||||
_, _, base := pluginHome(t)
|
||||
resp := postJSON(t, base+"/plugins/enabled", map[string]any{"name": "nope", "enabled": true})
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("POST /plugins/enabled for an unknown plugin = %d, want 422", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginRemove(t *testing.T) {
|
||||
home, ctl, base := pluginHome(t)
|
||||
src := t.TempDir()
|
||||
writePluginSource(t, src)
|
||||
postJSON(t, base+"/plugins/install", map[string]any{"source": src})
|
||||
|
||||
req, err := http.NewRequest(http.MethodDelete, base+"/plugins/demo", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("DELETE /plugins/demo = %d", resp.StatusCode)
|
||||
}
|
||||
if body := decodeInstallSource(t, resp); body["status"] != "done" {
|
||||
t.Fatalf("status = %v, want done: %v", body["status"], body)
|
||||
}
|
||||
if list := getPlugins(t, base); len(list) != 0 {
|
||||
t.Fatalf("plugins after remove = %+v", list)
|
||||
}
|
||||
if _, err := os.Stat(pluginpkg.InstallRoot(home, "demo")); !os.IsNotExist(err) {
|
||||
t.Fatalf("install root survived the remove: %v", err)
|
||||
}
|
||||
// The package's server has to leave the live session too; a reload that is
|
||||
// refused mid-turn would otherwise keep answering from a removed package.
|
||||
if len(ctl.disconnected) != 1 || ctl.disconnected[0] != "docs" {
|
||||
t.Fatalf("disconnected = %v, want the package's MCP server", ctl.disconnected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginExportStripsCredentials(t *testing.T) {
|
||||
_, _, base := pluginHome(t)
|
||||
src := t.TempDir()
|
||||
writePluginSource(t, src)
|
||||
writePluginFile(t, filepath.Join(src, ".mcp.json"),
|
||||
`{"mcpServers":{"docs":{"command":"docs","env":{"TOKEN":"sk-live-1"}}}}`)
|
||||
postJSON(t, base+"/plugins/install", map[string]any{"source": src})
|
||||
|
||||
resp, err := http.Get(base + "/plugins/demo/export")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET /plugins/demo/export = %d", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "application/zip" {
|
||||
t.Fatalf("Content-Type = %q", ct)
|
||||
}
|
||||
if got := resp.Header.Get("X-Reasonix-Required-Env"); got != "DOCS_TOKEN" {
|
||||
t.Fatalf("X-Reasonix-Required-Env = %q, want the variable the other machine must supply", got)
|
||||
}
|
||||
var body bytes.Buffer
|
||||
if _, err := body.ReadFrom(resp.Body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(body.Bytes(), []byte("sk-live-1")) {
|
||||
t.Fatal("the exported archive carries the literal credential")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginExportUnknownPlugin(t *testing.T) {
|
||||
_, _, base := pluginHome(t)
|
||||
resp, err := http.Get(base + "/plugins/nope/export")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("GET export for an uninstalled plugin = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A name arrives as a URL segment, so the route validates it before anything
|
||||
// resolves a path from it.
|
||||
func TestPluginRemoveRejectsInvalidName(t *testing.T) {
|
||||
_, _, base := pluginHome(t)
|
||||
req, err := http.NewRequest(http.MethodDelete, base+"/plugins/..%2Fetc", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("DELETE with a path-shaped name = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -478,7 +478,9 @@ func (s *Server) handler() http.Handler {
|
||||
mux.HandleFunc("GET /context", s.context)
|
||||
mux.HandleFunc("POST /submit", s.submit)
|
||||
s.registerExtensionRoutes(mux)
|
||||
s.registerPluginRoutes(mux)
|
||||
s.registerThemeRoutes(mux)
|
||||
s.registerSurfaceRoutes(mux)
|
||||
s.registerInboxRoutes(mux)
|
||||
s.registerProviderRoutes(mux)
|
||||
s.registerRoleRoutes(mux)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// surfaces.go — where the user decided an extension's surface belongs.
|
||||
package serve
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
// An extension asks for a place and the frontend offers a default; this is the
|
||||
// third answer, and it wins over both. It lives in config rather than in the
|
||||
// browser so a window reopened tomorrow puts things back where the user left
|
||||
// them, and it is keyed by surface rather than by plugin so one extension's two
|
||||
// surfaces can sit in different places.
|
||||
func (s *Server) registerSurfaceRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /surfaces", s.surfaceSlots)
|
||||
mux.HandleFunc("POST /surfaces", s.assignSurfaceSlot)
|
||||
}
|
||||
|
||||
const maxSurfaceSlots = 64
|
||||
|
||||
func (s *Server) surfaceSlots(w http.ResponseWriter, r *http.Request) {
|
||||
slots := map[string]string{}
|
||||
if cfg, err := config.Load(); err == nil {
|
||||
maps.Copy(slots, cfg.Desktop.SurfaceSlots)
|
||||
}
|
||||
writeJSONCached(w, r, map[string]any{"slots": slots})
|
||||
}
|
||||
|
||||
// assignSurfaceSlot records one placement. An empty slot is the user taking it
|
||||
// back out of the chrome, which is a real choice rather than a missing one, so
|
||||
// it removes the entry and lets the extension's own suggestion apply again.
|
||||
func (s *Server) assignSurfaceSlot(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Surface string `json:"surface"`
|
||||
Slot string `json:"slot"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
surface := strings.TrimSpace(req.Surface)
|
||||
slot := strings.TrimSpace(req.Slot)
|
||||
// The names are a frontend's own vocabulary, so this end checks only that
|
||||
// they are names: what a slot means is not knowable from here.
|
||||
if surface == "" || len(surface) > 128 || len(slot) > 64 {
|
||||
http.Error(w, "surface required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
edit := config.LoadForEdit(config.UserConfigPath())
|
||||
if edit.Desktop.SurfaceSlots == nil {
|
||||
edit.Desktop.SurfaceSlots = map[string]string{}
|
||||
}
|
||||
if slot == "" {
|
||||
delete(edit.Desktop.SurfaceSlots, surface)
|
||||
} else {
|
||||
if _, known := edit.Desktop.SurfaceSlots[surface]; !known && len(edit.Desktop.SurfaceSlots) >= maxSurfaceSlots {
|
||||
writeJSONStatus(w, http.StatusUnprocessableEntity, map[string]string{
|
||||
"error": "too many surface placements are recorded; clear one first",
|
||||
})
|
||||
return
|
||||
}
|
||||
edit.Desktop.SurfaceSlots[surface] = slot
|
||||
}
|
||||
if err := edit.SaveTo(config.UserConfigPath()); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
func surfaceServer(t *testing.T) string {
|
||||
t.Helper()
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
srv := httptest.NewServer(New(&pluginCtl{root: t.TempDir()}, NewBroadcaster(), config.ServeConfig{}).Handler())
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL
|
||||
}
|
||||
|
||||
func readSlots(t *testing.T, base string) map[string]string {
|
||||
t.Helper()
|
||||
resp, err := http.Get(base + "/surfaces")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var body struct {
|
||||
Slots map[string]string `json:"slots"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body.Slots
|
||||
}
|
||||
|
||||
// The user's placement outlives the window it was made in, which is the whole
|
||||
// reason it is config rather than something the page remembers.
|
||||
func TestSurfacePlacementPersists(t *testing.T) {
|
||||
base := surfaceServer(t)
|
||||
if got := readSlots(t, base); len(got) != 0 {
|
||||
t.Fatalf("slots = %v, want none before anything is placed", got)
|
||||
}
|
||||
if resp := postJSON(t, base+"/surfaces", map[string]any{
|
||||
"surface": "opengo:quota", "slot": "composer-trailing",
|
||||
}); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("POST /surfaces = %d", resp.StatusCode)
|
||||
}
|
||||
if got := readSlots(t, base)["opengo:quota"]; got != "composer-trailing" {
|
||||
t.Fatalf("slot = %q, want the placement to have been recorded", got)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil || cfg.Desktop.SurfaceSlots["opengo:quota"] != "composer-trailing" {
|
||||
t.Fatalf("config = %v (err %v)", cfg.Desktop.SurfaceSlots, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Clearing is a real choice: it hands the decision back to the extension's own
|
||||
// suggestion rather than pinning the surface out of sight.
|
||||
func TestEmptySlotClearsThePlacement(t *testing.T) {
|
||||
base := surfaceServer(t)
|
||||
postJSON(t, base+"/surfaces", map[string]any{"surface": "opengo:quota", "slot": "composer-trailing"})
|
||||
postJSON(t, base+"/surfaces", map[string]any{"surface": "opengo:quota", "slot": ""})
|
||||
if got := readSlots(t, base); len(got) != 0 {
|
||||
t.Fatalf("slots = %v, want the entry gone", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A slot name means something only to the frontend that offers it, so this end
|
||||
// stores what it is told. What it does refuse is a surface with no name, and an
|
||||
// unbounded number of placements.
|
||||
func TestSurfacePlacementValidatesOnlyWhatItCanKnow(t *testing.T) {
|
||||
base := surfaceServer(t)
|
||||
if resp := postJSON(t, base+"/surfaces", map[string]any{
|
||||
"surface": "plugin:s", "slot": "a-place-only-one-frontend-has",
|
||||
}); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("an unfamiliar slot name was rejected: %d", resp.StatusCode)
|
||||
}
|
||||
if resp := postJSON(t, base+"/surfaces", map[string]any{"slot": "composer-trailing"}); resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("a placement with no surface = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
for i := range maxSurfaceSlots {
|
||||
postJSON(t, base+"/surfaces", map[string]any{"surface": string(rune('a'+i)) + ":s", "slot": "x"})
|
||||
}
|
||||
if resp := postJSON(t, base+"/surfaces", map[string]any{"surface": "one-too-many:s", "slot": "x"}); resp.StatusCode == http.StatusNoContent {
|
||||
t.Error("placements grew without a bound")
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ type themeView struct {
|
||||
Tokens map[string]map[string]string `json:"tokens"`
|
||||
Background *theme.Background `json:"background,omitempty"`
|
||||
HasPreview bool `json:"hasPreview,omitempty"`
|
||||
// What the pack asked for and did not get. It rides on the listing so the
|
||||
// author sees it in the picker rather than only in a log they never read.
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// The list carries every pack's full token set, not just the active one's. A
|
||||
@@ -42,6 +45,7 @@ func (s *Server) themes(w http.ResponseWriter, r *http.Request) {
|
||||
ID: p.ID, Name: p.Name, Author: p.Author, Description: p.Description,
|
||||
Active: p.ID == active, Tokens: p.Tokens,
|
||||
Background: p.Background, HasPreview: p.HasPreview,
|
||||
Warnings: p.Warnings,
|
||||
})
|
||||
}
|
||||
writeJSONCached(w, r, out)
|
||||
|
||||
@@ -74,7 +74,7 @@ Operate as an installer, not as a shell-script guesser:
|
||||
- low → safe to apply without asking.
|
||||
- medium → safe to apply, but mention what was written.
|
||||
- high → ask the user to confirm in one short question before apply=true. High actions include MCP installs that send auth headers, eager-tier servers, link targets that are absolute paths outside the project/home root, and any replace=true on an existing entry.
|
||||
6. If the plan is acceptable and any needed user confirmation has happened, call install_source again with apply=true and echo back the same planId you got from the planning call. The tool refuses to apply when the planId does not match, so always re-fetch by running apply=false again if the user changed their mind about the source. Host permissions may still deny the apply call.
|
||||
6. If the plan is acceptable and any needed user confirmation has happened, call install_source again with apply=true and echo back the same planId you got from the planning call. An apply without that planId installs nothing and hands the plan back, and one whose planId does not match is refused, so always re-plan with apply=false if the user changed their mind about the source. The planId begins with the plan's own risk grade; a high one is put to the user by the permission layer no matter how permissive the session is.
|
||||
7. After apply=true, report what was installed, where it was persisted, and whether it is usable in the current session. For skills, prefer actions[].canonicalPath, actions[].installRoot, actions[].discoverable, and actions[].indexed over guessing from the source path. The plan's kinds field tells you how many skills vs MCP servers were touched.
|
||||
|
||||
Defaults:
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
"bgSoft": "#F9EEEC",
|
||||
"bgElev": "#FFFFFF",
|
||||
"panel": "#FFFFFF",
|
||||
"sidebar": "#F9ECEA",
|
||||
"chat": "#FFFBFA",
|
||||
"workspace": "#FFFFFF",
|
||||
"workspaceFiles": "#FBF1EF",
|
||||
"border": "#DFB3AD",
|
||||
"borderSoft": "#F6E4E2",
|
||||
"fg": "#301D1D",
|
||||
@@ -29,10 +25,6 @@
|
||||
"bgSoft": "#221318",
|
||||
"bgElev": "#2B181E",
|
||||
"panel": "#25141A",
|
||||
"sidebar": "#211218",
|
||||
"chat": "#201116",
|
||||
"workspace": "#2B181E",
|
||||
"workspaceFiles": "#221319",
|
||||
"border": "#582F3C",
|
||||
"borderSoft": "#361B24",
|
||||
"fg": "#FFF1F2",
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
"bgSoft": "#E4F5F7",
|
||||
"bgElev": "#FAFEFE",
|
||||
"panel": "#FAFEFE",
|
||||
"sidebar": "#E3F4F6",
|
||||
"chat": "#F6FDFE",
|
||||
"workspace": "#FAFEFE",
|
||||
"workspaceFiles": "#E8F6F8",
|
||||
"border": "#9CCFD7",
|
||||
"borderSoft": "#DAEDF0",
|
||||
"fg": "#173238",
|
||||
@@ -29,10 +25,6 @@
|
||||
"bgSoft": "#0C2229",
|
||||
"bgElev": "#112C34",
|
||||
"panel": "#0E252D",
|
||||
"sidebar": "#0B2128",
|
||||
"chat": "#0A2027",
|
||||
"workspace": "#112C34",
|
||||
"workspaceFiles": "#0C232A",
|
||||
"border": "#1F4550",
|
||||
"borderSoft": "#16333C",
|
||||
"fg": "#E9FCFF",
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
"bgSoft": "#F9EFD5",
|
||||
"bgElev": "#FFFDF6",
|
||||
"panel": "#FFFDF6",
|
||||
"sidebar": "#F8EDD3",
|
||||
"chat": "#FFFBF1",
|
||||
"workspace": "#FFFDF6",
|
||||
"workspaceFiles": "#FAF1DC",
|
||||
"border": "#D6B87E",
|
||||
"borderSoft": "#F2E7CD",
|
||||
"fg": "#382116",
|
||||
@@ -29,10 +25,6 @@
|
||||
"bgSoft": "#271C12",
|
||||
"bgElev": "#302417",
|
||||
"panel": "#291E13",
|
||||
"sidebar": "#251B11",
|
||||
"chat": "#231A11",
|
||||
"workspace": "#302417",
|
||||
"workspaceFiles": "#271D13",
|
||||
"border": "#4D3A22",
|
||||
"borderSoft": "#3B2C1A",
|
||||
"fg": "#FFF2D1",
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
"bgSoft": "#F6F0DF",
|
||||
"bgElev": "#FEFBF4",
|
||||
"panel": "#FEFBF4",
|
||||
"sidebar": "#F5EFDD",
|
||||
"chat": "#FDFAF2",
|
||||
"workspace": "#FEFBF4",
|
||||
"workspaceFiles": "#F7F1E1",
|
||||
"border": "#CCBE94",
|
||||
"borderSoft": "#EFE8D2",
|
||||
"fg": "#2A241B",
|
||||
@@ -29,10 +25,6 @@
|
||||
"bgSoft": "#15120E",
|
||||
"bgElev": "#1D1913",
|
||||
"panel": "#171410",
|
||||
"sidebar": "#14110D",
|
||||
"chat": "#131009",
|
||||
"workspace": "#1D1913",
|
||||
"workspaceFiles": "#15120E",
|
||||
"border": "#463B27",
|
||||
"borderSoft": "#2A2418",
|
||||
"fg": "#F8F1DF",
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
"bgSoft": "#FBEFF1",
|
||||
"bgElev": "#FFFFFF",
|
||||
"panel": "#FFFFFF",
|
||||
"sidebar": "#FBECEF",
|
||||
"chat": "#FFFCFC",
|
||||
"workspace": "#FFFFFF",
|
||||
"workspaceFiles": "#FDF3F4",
|
||||
"border": "#E2BCC6",
|
||||
"borderSoft": "#F5E3E7",
|
||||
"fg": "#3A252C",
|
||||
@@ -29,10 +25,6 @@
|
||||
"bgSoft": "#281B21",
|
||||
"bgElev": "#312329",
|
||||
"panel": "#2A1D24",
|
||||
"sidebar": "#251922",
|
||||
"chat": "#231820",
|
||||
"workspace": "#312329",
|
||||
"workspaceFiles": "#281C22",
|
||||
"border": "#4C3440",
|
||||
"borderSoft": "#3A2832",
|
||||
"fg": "#FFF3F6",
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
"bgSoft": "#EFEFE2",
|
||||
"bgElev": "#FCFCF6",
|
||||
"panel": "#FCFCF6",
|
||||
"sidebar": "#EEEEE0",
|
||||
"chat": "#FAFAF4",
|
||||
"workspace": "#FCFCF6",
|
||||
"workspaceFiles": "#F1F1E5",
|
||||
"border": "#BFC095",
|
||||
"borderSoft": "#E8E8D8",
|
||||
"fg": "#26332D",
|
||||
@@ -29,10 +25,6 @@
|
||||
"bgSoft": "#17211C",
|
||||
"bgElev": "#1E2922",
|
||||
"panel": "#19231D",
|
||||
"sidebar": "#161F1A",
|
||||
"chat": "#151E19",
|
||||
"workspace": "#1E2922",
|
||||
"workspaceFiles": "#17211C",
|
||||
"border": "#32443A",
|
||||
"borderSoft": "#25322B",
|
||||
"fg": "#EEF6F0",
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
"bgSoft": "#F9F1DE",
|
||||
"bgElev": "#FFFDF7",
|
||||
"panel": "#FFFDF7",
|
||||
"sidebar": "#F8F0DC",
|
||||
"chat": "#FFFCF4",
|
||||
"workspace": "#FFFDF7",
|
||||
"workspaceFiles": "#FAF3E2",
|
||||
"border": "#D2BC8E",
|
||||
"borderSoft": "#F2E9D4",
|
||||
"fg": "#2B2F35",
|
||||
@@ -29,10 +25,6 @@
|
||||
"bgSoft": "#1B1F23",
|
||||
"bgElev": "#23282D",
|
||||
"panel": "#1D2226",
|
||||
"sidebar": "#1A1E22",
|
||||
"chat": "#191D21",
|
||||
"workspace": "#23282D",
|
||||
"workspaceFiles": "#1B2024",
|
||||
"border": "#38404A",
|
||||
"borderSoft": "#2A3138",
|
||||
"fg": "#F8F5E9",
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
"bgSoft": "#EFEBFB",
|
||||
"bgElev": "#FCFAFF",
|
||||
"panel": "#FCFAFF",
|
||||
"sidebar": "#EEE9FA",
|
||||
"chat": "#FAF8FF",
|
||||
"workspace": "#FCFAFF",
|
||||
"workspaceFiles": "#F1EDFB",
|
||||
"border": "#CABDEC",
|
||||
"borderSoft": "#E8E3F7",
|
||||
"fg": "#251F3C",
|
||||
@@ -29,10 +25,6 @@
|
||||
"bgSoft": "#131834",
|
||||
"bgElev": "#1A2140",
|
||||
"panel": "#151B36",
|
||||
"sidebar": "#121732",
|
||||
"chat": "#111630",
|
||||
"workspace": "#1A2140",
|
||||
"workspaceFiles": "#131834",
|
||||
"border": "#2E3A66",
|
||||
"borderSoft": "#222B4E",
|
||||
"fg": "#F4F2FF",
|
||||
|
||||
+15
-3
@@ -42,6 +42,9 @@ type Pack struct {
|
||||
Tokens map[string]map[string]string `json:"tokens"`
|
||||
Background *Background `json:"background,omitempty"`
|
||||
HasPreview bool `json:"hasPreview,omitempty"`
|
||||
// Warnings names the tokens that were dropped and why. The pack still
|
||||
// loads: one bad value must not cost the author every good one.
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// Background is how a pack's image wants to be placed, not just that it has
|
||||
@@ -182,22 +185,31 @@ func decode(raw []byte, dirID string) (Pack, error) {
|
||||
name = id
|
||||
}
|
||||
tokens := map[string]map[string]string{}
|
||||
var warnings []string
|
||||
for _, scheme := range []string{"light", "dark"} {
|
||||
if len(m.Tokens[scheme]) == 0 {
|
||||
return Pack{}, fmt.Errorf("theme %s: no %s tokens", id, scheme)
|
||||
}
|
||||
copied := make(map[string]string, len(m.Tokens[scheme]))
|
||||
for k, v := range m.Tokens[scheme] {
|
||||
if isColour(v) {
|
||||
if validToken(k, v) {
|
||||
copied[k] = v
|
||||
continue
|
||||
}
|
||||
// A dropped token is reported rather than silently ignored: the
|
||||
// author is the only one who can fix it, and a pack that half
|
||||
// applies looks like the app is broken rather than the pack.
|
||||
warnings = append(warnings, dropReason(scheme, k, v))
|
||||
}
|
||||
if len(copied) == 0 {
|
||||
return Pack{}, fmt.Errorf("theme %s: no usable %s colours", id, scheme)
|
||||
return Pack{}, fmt.Errorf("theme %s: no usable %s tokens", id, scheme)
|
||||
}
|
||||
tokens[scheme] = copied
|
||||
}
|
||||
pack := Pack{ID: id, Name: name, Author: strings.TrimSpace(m.Author), Description: strings.TrimSpace(m.Description), Tokens: tokens}
|
||||
// Map iteration order would otherwise make the same pack report its
|
||||
// problems in a different order on every read.
|
||||
sort.Strings(warnings)
|
||||
pack := Pack{ID: id, Name: name, Author: strings.TrimSpace(m.Author), Description: strings.TrimSpace(m.Description), Tokens: tokens, Warnings: warnings}
|
||||
pack.Background = backgroundOf(m.Background, hasAsset(id, assetBackground))
|
||||
pack.HasPreview = hasAsset(id, assetPreview)
|
||||
return pack, nil
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// tokens.go — what a pack may set, and what a value of that kind may look like.
|
||||
package theme
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// TokenKind classifies a token by the CSS grammar its value ends up in. Every
|
||||
// value is interpolated into a stylesheet, so each kind has one strict pattern
|
||||
// and a value that does not match is dropped rather than escaped.
|
||||
type TokenKind string
|
||||
|
||||
const (
|
||||
TokenColour TokenKind = "colour"
|
||||
TokenLength TokenKind = "length"
|
||||
TokenFont TokenKind = "font"
|
||||
)
|
||||
|
||||
// Tokens is the pack-facing vocabulary and the only one; a frontend maps these
|
||||
// onto its own CSS variables, which stay ours to rename. What is absent is
|
||||
// absent on purpose: ok/warn/err/net/deleg carry meaning rather than taste, a
|
||||
// pill radius is a shape rather than a size, and a pack that could stretch a
|
||||
// transition could make the app feel broken without ever looking wrong.
|
||||
var Tokens = map[string]TokenKind{
|
||||
"bg": TokenColour,
|
||||
"bgSoft": TokenColour,
|
||||
"bgElev": TokenColour,
|
||||
"panel": TokenColour,
|
||||
"border": TokenColour,
|
||||
"borderSoft": TokenColour,
|
||||
"fg": TokenColour,
|
||||
"fgDim": TokenColour,
|
||||
"fgFaint": TokenColour,
|
||||
"accent": TokenColour,
|
||||
"accentFg": TokenColour,
|
||||
"radiusXs": TokenLength,
|
||||
"radiusSm": TokenLength,
|
||||
"radiusMd": TokenLength,
|
||||
"fontUi": TokenFont,
|
||||
"fontMono": TokenFont,
|
||||
}
|
||||
|
||||
// TokenNames returns the vocabulary sorted, for callers that report it.
|
||||
func TokenNames() []string {
|
||||
out := make([]string, 0, len(Tokens))
|
||||
for name := range Tokens {
|
||||
out = append(out, name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// validToken reports whether name is in the vocabulary and value fits its kind.
|
||||
func validToken(name, value string) bool {
|
||||
switch Tokens[name] {
|
||||
case TokenColour:
|
||||
return isColour(value)
|
||||
case TokenLength:
|
||||
return isLength(value)
|
||||
case TokenFont:
|
||||
return isFontStack(value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// maxRadiusPx bounds a length so a pack cannot round a card into a circle by
|
||||
// accident. It is a sanity limit, not a taste one — 64px is already extreme.
|
||||
const maxRadiusPx = 64
|
||||
|
||||
// isLength keeps a plain px or rem length. No calc(), no var(), no unitless
|
||||
// value that would silently mean nothing.
|
||||
func isLength(v string) bool {
|
||||
v = strings.TrimSpace(v)
|
||||
// Zero is the one length CSS writes without a unit, and an author squaring
|
||||
// off every corner will write it that way.
|
||||
if v == "0" {
|
||||
return true
|
||||
}
|
||||
var digits string
|
||||
scale := 1.0
|
||||
switch {
|
||||
case strings.HasSuffix(v, "px"):
|
||||
digits = strings.TrimSuffix(v, "px")
|
||||
case strings.HasSuffix(v, "rem"):
|
||||
digits, scale = strings.TrimSuffix(v, "rem"), 16
|
||||
default:
|
||||
return false
|
||||
}
|
||||
// plainDecimal has already refused the empty string, the signs and the
|
||||
// exponents, so what reaches ParseFloat is a non-negative decimal.
|
||||
if !plainDecimal(digits) {
|
||||
return false
|
||||
}
|
||||
n, err := strconv.ParseFloat(digits, 64)
|
||||
return err == nil && n*scale <= maxRadiusPx
|
||||
}
|
||||
|
||||
// plainDecimal is digits with at most one dot — what CSS is written in, minus
|
||||
// the signs, exponents and infinities strconv would otherwise accept.
|
||||
func plainDecimal(s string) bool {
|
||||
if s == "" || s == "." || len(s) > 8 {
|
||||
return false
|
||||
}
|
||||
dot := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
case r == '.' && !dot:
|
||||
dot = true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isFontStack keeps a family list: names, separators, and quotes. Everything
|
||||
// that could end the declaration and start another one — ; } ( ) \ @ — or
|
||||
// reach the network through url() is absent from the allowed set, so the value
|
||||
// cannot leave the font-family property it is written into.
|
||||
func isFontStack(v string) bool {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" || len(v) > 200 {
|
||||
return false
|
||||
}
|
||||
for _, r := range v {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
case r == ' ' || r == ',' || r == '-' || r == '_' || r == '.' || r == '\'' || r == '"':
|
||||
case r > 0x7F && unicode.IsLetter(r):
|
||||
// A CJK family name is spelled in its own script. Letters only, so
|
||||
// no control or formatting character rides in on this branch.
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// dropReason says which half of the contract a token missed: the vocabulary or
|
||||
// the grammar of its kind. Both are actionable, and they are different fixes.
|
||||
func dropReason(scheme, name, value string) string {
|
||||
kind, known := Tokens[name]
|
||||
if !known {
|
||||
return fmt.Sprintf("%s: %q is not a theme token", scheme, name)
|
||||
}
|
||||
return fmt.Sprintf("%s: %q is not a valid %s value for %q", scheme, value, kind, name)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package theme
|
||||
|
||||
import "testing"
|
||||
|
||||
// A value lands inside a stylesheet, so the question is not "is this sensible"
|
||||
// but "can this leave the property it was written into". Everything that could
|
||||
// close a declaration, open a function, or fetch a URL has to be refused.
|
||||
func TestTokenValuesCannotEscapeTheirDeclaration(t *testing.T) {
|
||||
escapes := []struct{ name, value string }{
|
||||
{"bg", "red; background-image: url(https://evil.example/x.png)"},
|
||||
{"bg", "#fff; }"},
|
||||
{"bg", "var(--err)"},
|
||||
{"radiusSm", "8px; position: fixed"},
|
||||
{"radiusSm", "calc(100% - 2px)"},
|
||||
{"radiusSm", "var(--r-md)"},
|
||||
{"fontUi", `Arial; } body { display: none`},
|
||||
{"fontUi", `local("x"), url(https://evil.example/f.woff2)`},
|
||||
{"fontUi", "Arial\\3b color:red"},
|
||||
}
|
||||
for _, esc := range escapes {
|
||||
if validToken(esc.name, esc.value) {
|
||||
t.Errorf("validToken(%q, %q) accepted a value that can leave its declaration", esc.name, esc.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenValuesAcceptWhatAPackActuallyWrites(t *testing.T) {
|
||||
ok := []struct{ name, value string }{
|
||||
{"bg", "#0F0D0B"},
|
||||
{"accent", "#d89b5a"},
|
||||
{"accentFg", "#fff"},
|
||||
{"bgSoft", "#11223344"},
|
||||
{"radiusXs", "3px"},
|
||||
{"radiusMd", "0.5rem"},
|
||||
{"radiusSm", "0"},
|
||||
{"fontUi", `-apple-system, "Segoe UI", sans-serif`},
|
||||
{"fontMono", `ui-monospace, "SF Mono", Menlo, monospace`},
|
||||
{"fontUi", "苹方, PingFang SC, sans-serif"},
|
||||
}
|
||||
for _, entry := range ok {
|
||||
if !validToken(entry.name, entry.value) {
|
||||
t.Errorf("validToken(%q, %q) refused a value a pack would reasonably write", entry.name, entry.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A radius is a size, and a size has a ceiling: past it the value is not a
|
||||
// rounder card, it is a different shape.
|
||||
func TestLengthHasACeiling(t *testing.T) {
|
||||
if validToken("radiusMd", "999px") {
|
||||
t.Error("a 999px radius was accepted; that is a pill, not a rounded corner")
|
||||
}
|
||||
if !validToken("radiusMd", "64px") {
|
||||
t.Error("the documented ceiling was refused")
|
||||
}
|
||||
}
|
||||
|
||||
// A name outside the vocabulary is refused whatever its value looks like: that
|
||||
// is what stops a pack from reaching a variable the frontend never meant to
|
||||
// hand over, including the ones that carry meaning.
|
||||
func TestUnknownAndReservedNamesAreRefused(t *testing.T) {
|
||||
for _, name := range []string{"ok", "err", "radiusPill", "bgColor", ""} {
|
||||
if validToken(name, "#ffffff") {
|
||||
t.Errorf("validToken accepted %q, which is not in the vocabulary", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The packs that ship are held to the vocabulary they document. Introducing
|
||||
// this check found four dead tokens in every one of them — chat/sidebar/
|
||||
// workspace/workspaceFiles, named for regions of an editor this frontend does
|
||||
// not have — which had been silently dropped since the packs were ported.
|
||||
func TestShippedPacksUseOnlyRealTokens(t *testing.T) {
|
||||
packs := listBuiltin()
|
||||
if len(packs) == 0 {
|
||||
t.Fatal("no packs ship; the embed is broken")
|
||||
}
|
||||
for _, pack := range packs {
|
||||
if len(pack.Warnings) > 0 {
|
||||
t.Errorf("%s: %v", pack.ID, pack.Warnings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The pack still loads when one value is wrong, and says which one. Dropping
|
||||
// the whole pack would cost the author every good token for one typo; dropping
|
||||
// the token silently would leave them with no way to find it.
|
||||
func TestDecodeKeepsGoodTokensAndReportsBadOnes(t *testing.T) {
|
||||
pack, err := decode([]byte(`{
|
||||
"schemaVersion": 1,
|
||||
"name": "Partial",
|
||||
"tokens": {
|
||||
"light": {"bg": "#ffffff", "fg": "#000000", "radiusSm": "huge", "glow": "#ff0000"},
|
||||
"dark": {"bg": "#000000", "fg": "#ffffff"}
|
||||
}
|
||||
}`), "partial")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := pack.Tokens["light"]["bg"]; got != "#ffffff" {
|
||||
t.Fatalf("good token was lost: %q", got)
|
||||
}
|
||||
if _, present := pack.Tokens["light"]["radiusSm"]; present {
|
||||
t.Error("an invalid length survived into the pack")
|
||||
}
|
||||
if len(pack.Warnings) != 2 {
|
||||
t.Fatalf("warnings = %v, want one for the bad value and one for the unknown name", pack.Warnings)
|
||||
}
|
||||
}
|
||||
@@ -505,6 +505,7 @@ const (
|
||||
UISurfaceForm UISurfaceKind = "form"
|
||||
UISurfaceNotification UISurfaceKind = "notification"
|
||||
UISurfacePanel UISurfaceKind = "panel"
|
||||
UISurfaceView UISurfaceKind = "view"
|
||||
)
|
||||
|
||||
// UIPublishResult is a generated Extension Protocol v2 wire DTO.
|
||||
@@ -619,6 +620,51 @@ type UIPanelPayload struct {
|
||||
Actions []UIActionRef `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
// UIViewPayload is a generated Extension Protocol v2 wire DTO.
|
||||
type UIViewPayload struct {
|
||||
Slot string `json:"slot,omitempty"`
|
||||
Body []UINode `json:"body"`
|
||||
}
|
||||
|
||||
// UINode is a generated Extension Protocol v2 wire DTO.
|
||||
type UINode struct {
|
||||
Kind UINodeKind `json:"kind"`
|
||||
Value string `json:"value,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Tone UITone `json:"tone,omitempty"`
|
||||
Progress *float64 `json:"progress,omitempty"`
|
||||
ActionID string `json:"actionId,omitempty"`
|
||||
Children []UINode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// UINodeKind is a generated Extension Protocol v2 string enum.
|
||||
type UINodeKind string
|
||||
|
||||
const (
|
||||
UINodeText UINodeKind = "text"
|
||||
UINodeMarkdown UINodeKind = "markdown"
|
||||
UINodeRow UINodeKind = "row"
|
||||
UINodeStack UINodeKind = "stack"
|
||||
UINodeKv UINodeKind = "kv"
|
||||
UINodeMeter UINodeKind = "meter"
|
||||
UINodePip UINodeKind = "pip"
|
||||
UINodeButton UINodeKind = "button"
|
||||
UINodeDivider UINodeKind = "divider"
|
||||
)
|
||||
|
||||
// UITone is a generated Extension Protocol v2 string enum.
|
||||
type UITone string
|
||||
|
||||
const (
|
||||
UIToneDim UITone = "dim"
|
||||
UIToneStrong UITone = "strong"
|
||||
UIToneOk UITone = "ok"
|
||||
UIToneWarn UITone = "warn"
|
||||
UIToneErr UITone = "err"
|
||||
UIToneAccent UITone = "accent"
|
||||
)
|
||||
|
||||
// ProtocolErrorData is a generated Extension Protocol v2 wire DTO.
|
||||
type ProtocolErrorData struct {
|
||||
Reason ErrorReason `json:"reason"`
|
||||
|
||||
Reference in new issue
Block a user