Compare commits

...

4 Commits

Author SHA1 Message Date
8ff0f475de feat(sdk): contrat permissions.workspaceExec pour l'exécution contrôlée via l'hôte — #346
Cadrage capacités plugin SCM (#346): le manifeste plugin gagne
permissions.workspaceExec.allow — règles d'exécution contrôlée par l'hôte
(commande bare ASCII résolue sans shell, argv exact avec jeton `*` en
suffixe, cwd project-relative sûr, effect read/write/network/destructive,
64 règles max) — au lieu de lectures ad hoc du store interne .git.
Validation manifeste + exports runtime/index + docs README/manifest/
services + auto-check script étendu (règles valides acceptées, règles
unsafe rejetées).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 12:07:56 +02:00
720cd53924 merge(sdk): fix #336 — contrat docs commandes/menus apres hot reload (erreur retryable, feedback host-owned, contrat manifest/registration, reload sans refresh auto)
# Conflicts:
#	docs/menus.md
2026-09-12 14:06:54 +02:00
9efb781636 docs(sdk): rejet de promesse windows.open remonte au handler + rejet des commandes stale d'un runtime remplace (identite plugin) — #336 (docs only, aucun push) 2026-09-12 14:06:00 +02:00
116b75a024 docs(sdk): contrat commandes/menus apres reload — handler manquant = erreur retryable jamais no-op silencieux, feedback skipped/failed host-owned, pas de contributes.commands separe, reload sans refresh auto par le plugin — #336 (docs only, aucun push) 2026-09-12 14:05:52 +02:00
11 changed files with 229 additions and 15 deletions

View File

@ -14,7 +14,8 @@ exports `activate(ctx)`.
- [Commands And Feedback](docs/commands-and-feedback.md): command return values, skipped work and background tasks.
- [Layouts With React](docs/layouts-react.md): React/JSX/hooks layout authoring with host-provided React.
- [Windows](docs/windows.md): opening plugin layouts in detached OS windows.
- [Services](docs/services.md): workspace, tasks, tooling, events, config, terminal and windows facades.
- [Services](docs/services.md): workspace files and bounded `workspace.execTool`, tasks,
tooling, events, config, terminal and windows facades.
- [Packaging And Distribution](docs/packaging-distribution.md): build output, archive layout, hot reload and dependency rules.
- [Codex Skills And IdeA Plugins](docs/codex-skills-and-idea-plugins.md): separate IdeA-native plugin skills from Codex plugin discovery.
- [Project Plugin Assignments](docs/project-plugin-assignments.md): enable plugins per project and assign skills and MCP tools to agents.
@ -23,6 +24,12 @@ The installable example lives in [`examples/hello-plugin`](examples/hello-plugin
It demonstrates a menu command, storage, background-task feedback, a React layout
component and `services.windows.open(...)`.
For short project-scoped external tools, use
`ctx.services.workspace.execTool()` with an explicit manifest
`permissions.workspaceExec` allow rule. See [Services](docs/services.md) and
[Manifest](docs/manifest.md). The primitive is generic and does not expose a
native Git/SCM API.
## Install
```sh

View File

@ -25,9 +25,10 @@ manifest contribution -> command id -> registered command handler -> optional ta
not by a fake task.
- Feedback objects must be stable enough for agents and programmatic callers to
parse, and their messages must be readable by humans.
- The current human menu-click UI does not guarantee display of a command
handler return value. When a human must see feedback from a menu action, open
or focus a plugin layout/window from the handler with
- The human menu-click UI presents minimal host-owned feedback for `skipped`
and `failed` results. It intentionally does not expose the raw result payload.
For successful actions and richer status, open or focus a plugin layout/window
from the handler with
`ctx.services.windows.open({ layoutType, state })`.
## Preconditions
@ -78,9 +79,9 @@ Use the same `status` vocabulary consistently:
Visible surfaces are intentionally distinct:
- Command return value: immediate feedback for programmatic callers, agents and
future host surfaces. It is not a guaranteed visible UI surface for current
human menu clicks.
- Command return value: immediate feedback for programmatic callers and agents.
The human menu host maps `skipped` and `failed` results to concise feedback;
plugin-specific fields and messages are not displayed verbatim.
- Plugin layout/window: the normative visible feedback surface for menu actions
that need to show status, results or next steps to a human. The layout must be
declared in `idea-plugin.json` and registered during activation.

View File

@ -141,6 +141,39 @@ development loop.
- `activationScope`: `"app"` by default, or `"project"` when activation needs a
focused project immediately.
- `capabilities`: `"ui"`, `"tooling"` and/or `"mcp"`.
- `permissions.workspaceExec`: explicit generic executable rules used by
`ctx.services.workspace.execTool()`.
## Workspace execution permissions
Short project-scoped executable access is denied unless the active plugin
manifest contains a matching rule:
```json
{
"permissions": {
"workspaceExec": {
"allow": [
{
"command": "tool",
"args": ["status", "*"],
"cwd": ".",
"effect": "read"
}
]
}
}
}
```
`command` is a bare ASCII executable name; paths and shell command lines are
rejected. `args` is an argv pattern: literal tokens match exactly, `*` matches
one token, and a final `*` matches the remaining suffix (including no token).
`cwd` is an exact normalized project-relative directory; `*` permits any cwd
that the host still proves is under the project root. `effect` is one of
`read`, `write`, `network`, or `destructive` and drives sandbox/audit policy.
The manifest declares generic process access only; it does not add native SCM
semantics to IdeA.
## Contributions
@ -243,7 +276,9 @@ public id used by both `ctx.layouts.register({ type, component })` and
Contribution ids are part of the runtime contract:
- commands can only register ids declared by `contributes.menuItems[*].command`;
- there is no separate `contributes.commands` collection; commands can only
register ids declared by `contributes.menuItems[*].command` or
`contributes.slashCommands[*].command`;
- layouts can only register `type` values declared by `contributes.layouts`;
- `services.windows.open({ layoutType })` only accepts a layout type declared by
the calling plugin.

View File

@ -41,9 +41,15 @@ export function activate(ctx: ActivateContext): void {
## Rules
- A command can only be registered if at least one manifest menu item declares
that exact `command` id.
- Missing command handlers are a no-op when the user clicks the menu item.
- There is no separate `contributes.commands` collection. A command can only be
registered if at least one manifest `menuItems[].command` or
`slashCommands[].command` entry declares that exact id.
- A missing command handler is surfaced by the host as a retryable reload error;
menu clicks must never fail silently.
- A registered handler's rejected promise is not swallowed: the host logs the
plugin id, command id and reason, then presents a retryable error. In
particular, return/await `services.windows.open(...)` so native-window
failures follow this path.
- A handler owns precondition checks and feedback. See
[Commands And Feedback](commands-and-feedback.md).
- Menu item `when` expressions are evaluated by the host; unsupported or false

View File

@ -150,6 +150,8 @@ These actions solve different problems:
- Reload the plugin after rebuilding or replacing its installed package. Reload
refreshes the plugin manifest, runtime bundle, contributions, and packaged
skill Markdown. It does not change project activation or agent assignments.
Plugins do not call a contribution-refresh API themselves: rebuild or replace
the installed package, then use the host reload action.
- Relaunch the agent after enabling or disabling a project plugin, changing its
skill/tool assignments, or manually editing `.ideai/project-plugins.json`.
Agent capabilities are resolved into a launch-time snapshot, so an already

View File

@ -29,6 +29,38 @@ Key APIs:
- `stat(path, projectId?)`
- `watch(path, handler, projectId?)`
- `queryStructure(query?)`
- `execTool(request)`
### Short external tools
`services.workspace.execTool()` runs a short external executable already available
to the host. It is generic: IdeA does not expose Git, SVN, formatter, or linter
semantics. The plugin owns the arguments and parses the returned output.
```ts
const result = await ctx.services!.workspace.execTool({
projectId,
command: "tool",
args: ["status", "--short"],
cwd: ".",
timeoutMs: 5_000,
maxStdoutBytes: 256_000,
maxStderrBytes: 64_000
});
```
The host passes `command` and `args` directly without a shell. The plugin must
declare a matching `permissions.workspaceExec.allow` rule, be active for the
project, and use a project-relative `cwd`. IdeA canonicalizes the cwd, bounds
time/stdin/output/concurrency, exposes only an allowlisted environment, applies
the project filesystem sandbox where supported, and audits metadata without
captured stdout/stderr contents. This API returns output directly and never
creates a visible Work/background-task entry.
Current host maxima are 30 seconds, 1,000,000 stdout bytes, 256,000 stderr bytes,
and 64,000 stdin bytes. Requested output/time values above a maximum are clamped;
zero is rejected. Output is returned as lossy UTF-8 with explicit truncation
flags.
## Tasks

View File

@ -24,6 +24,13 @@ do not currently guarantee that return value is displayed.
existing `contributes.layouts` layout.
- The host validates the plugin is runtime-active and the layout exists before
opening or focusing the window.
- The calling plugin package must still be installed, enabled globally and
enabled for the focused project. A stale command from a replaced runtime is
rejected instead of opening a window under another plugin's identity.
- `windows.open(...)` rejects its promise when any validation or native-window
operation fails. Let that rejection reach the command handler (or catch it,
log it and return explicit failed feedback); the IdeA menu host logs the
rejection and shows a retryable error to the user.
- Reopening the same plugin/layout pair focuses the existing window.
- `state` is JSON-serializable initial window state. The mounted component can
later call `setState(next)` to update its local host state.
@ -47,13 +54,14 @@ The returned `label` is a host-owned window identity. Treat it as opaque.
```ts
ctx.commands?.registerCommand("hello-plugin.open-dashboard", async () => {
const win = await ctx.services?.windows.open({
if (!ctx.services) {
return { status: "skipped", reason: "ui-service-unavailable", message: "UI service unavailable." };
}
const win = await ctx.services.windows.open({
layoutType: "hello-plugin.dashboard",
state: { source: "menu" }
});
return win
? { status: "opened", message: "Opened dashboard.", alreadyOpen: win.alreadyOpen }
: { status: "skipped", reason: "ui-service-unavailable", message: "UI service unavailable." };
return { status: "opened", message: "Opened dashboard.", alreadyOpen: win.alreadyOpen };
});
```

View File

@ -36,3 +36,26 @@ duplicate.contributes.mcpServers[0].tools.push({ name: "read_scene" });
const duplicateResult = validatePluginManifest(duplicate);
assert.equal(duplicateResult.success, false, "duplicate tool metadata is rejected");
assert.ok(duplicateResult.errors.some((error) => error.includes("must be unique")));
const withWorkspaceExec = structuredClone(base);
withWorkspaceExec.permissions = {
workspaceExec: {
allow: [{ command: "tool", args: ["status", "*"], cwd: ".", effect: "read" }]
}
};
assert.equal(
validatePluginManifest(withWorkspaceExec).success,
true,
"valid generic workspace execution permissions are accepted"
);
for (const invalidRule of [
{ command: "sh -c", args: [], cwd: ".", effect: "read" },
{ command: "tool", args: ["sta*"], cwd: ".", effect: "read" },
{ command: "tool", args: [], cwd: "../outside", effect: "read" },
{ command: "tool", args: [], cwd: ".", effect: "git" }
]) {
const invalid = structuredClone(base);
invalid.permissions = { workspaceExec: { allow: [invalidRule] } };
assert.equal(validatePluginManifest(invalid).success, false, "unsafe workspace exec rule is rejected");
}

View File

@ -2,6 +2,9 @@ export type {
IdeAPluginActivationScope,
IdeAPluginCapability,
IdeAPluginManifest,
IdeAPluginPermissions,
IdeAPluginWorkspaceExecEffect,
IdeAPluginWorkspaceExecRule,
IdeAPluginEngineConstraints,
IdeAPluginLayoutContribution,
IdeAPluginMcpServerContribution,
@ -78,6 +81,8 @@ export type {
WorkspaceBinaryFile,
WorkspaceDirEntry,
WorkspaceDirectoryListing,
WorkspaceExecToolRequest,
WorkspaceExecToolResult,
WorkspaceFileChangedEvent,
WorkspaceProject,
WorkspaceResolvedPath,

View File

@ -13,6 +13,7 @@ export interface IdeAPluginManifest {
* Defaults to "app". Use "project" when activate(ctx) requires a focused project.
*/
activationScope?: IdeAPluginActivationScope;
permissions?: IdeAPluginPermissions;
contributes?: {
skills?: IdeAPluginSkillContribution[];
menus?: IdeAPluginTopLevelMenuContribution[];
@ -23,6 +24,24 @@ export interface IdeAPluginManifest {
};
}
export type IdeAPluginWorkspaceExecEffect = "read" | "write" | "network" | "destructive";
export interface IdeAPluginWorkspaceExecRule {
/** Bare executable name resolved by the host without a shell. */
command: string;
/** Exact argv tokens. `*` matches one token, or the remaining suffix when last. */
args?: string[];
/** Exact project-relative cwd; `*` permits any host-confined cwd. Defaults to `.`. */
cwd?: string;
effect: IdeAPluginWorkspaceExecEffect;
}
export interface IdeAPluginPermissions {
workspaceExec?: {
allow: IdeAPluginWorkspaceExecRule[];
};
}
export type IdeAPluginCapability = "ui" | "mcp" | "tooling";
export type IdeAPluginActivationScope = "app" | "project";
@ -145,6 +164,7 @@ export function validatePluginManifest(input: unknown): PluginManifestValidation
validateEngines(input.engines, errors);
validateCapabilities(input.capabilities, errors);
validateActivationScope(input.activationScope, errors);
validatePermissions(input.permissions, errors);
validateContributes(input.contributes, errors);
if (errors.length > 0) {
@ -154,6 +174,56 @@ export function validatePluginManifest(input: unknown): PluginManifestValidation
return { success: true, data: input as unknown as IdeAPluginManifest, errors: [] };
}
function validatePermissions(value: unknown, errors: string[]): void {
if (value === undefined) return;
if (!isRecord(value)) {
errors.push("permissions must be an object when provided");
return;
}
const workspaceExec = value.workspaceExec;
if (workspaceExec === undefined) return;
if (!isRecord(workspaceExec) || !Array.isArray(workspaceExec.allow)) {
errors.push("permissions.workspaceExec.allow must be an array");
return;
}
if (workspaceExec.allow.length > 64) {
errors.push("permissions.workspaceExec.allow must contain at most 64 rules");
}
workspaceExec.allow.forEach((rule, index) => {
const label = `permissions.workspaceExec.allow[${index}]`;
if (!isRecord(rule)) {
errors.push(`${label} must be an object`);
return;
}
requireString(rule, "command", errors, `${label}.command`);
if (typeof rule.command === "string" && !/^[A-Za-z0-9._+-]{1,128}$/.test(rule.command)) {
errors.push(`${label}.command must be a bare ASCII executable name`);
}
optionalStringArray(rule, "args", errors, `${label}.args`);
if (Array.isArray(rule.args)) {
if (rule.args.length > 64 || rule.args.some((arg) =>
typeof arg === "string" && (arg.length === 0 || arg.length > 4096 || (arg.includes("*") && arg !== "*")))) {
errors.push(`${label}.args contains an invalid token`);
}
}
optionalString(rule, "cwd", errors, `${label}.cwd`);
if (typeof rule.cwd === "string" && rule.cwd !== "*" && !isSafeWorkspaceCwd(rule.cwd)) {
errors.push(`${label}.cwd must be project-relative without empty, dot, or parent segments`);
}
if (!["read", "write", "network", "destructive"].includes(String(rule.effect))) {
errors.push(`${label}.effect must be "read", "write", "network" or "destructive"`);
}
});
}
function isSafeWorkspaceCwd(value: string): boolean {
if (value === ".") return true;
if (value.length === 0 || value.startsWith("/") || value.startsWith("\\") || value.includes(":")) {
return false;
}
return value.replaceAll("\\", "/").split("/").every((part) => part !== "" && part !== "." && part !== "..");
}
export function isPluginManifest(input: unknown): input is IdeAPluginManifest {
return validatePluginManifest(input).success;
}

View File

@ -137,6 +137,31 @@ export interface WorkspaceService {
watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise<WorkspaceWatch>;
/** Queries a bounded, generic project structure read model. */
queryStructure(query?: WorkspaceStructureQuery): Promise<ProjectStructure>;
/** Runs one manifest-allowlisted binary directly, without a visible task. */
execTool(request: WorkspaceExecToolRequest): Promise<WorkspaceExecToolResult>;
}
export interface WorkspaceExecToolRequest {
projectId?: string;
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
timeoutMs?: number;
maxStdoutBytes?: number;
maxStderrBytes?: number;
stdin?: string;
}
export interface WorkspaceExecToolResult {
exitCode: number | null;
signal?: string;
timedOut: boolean;
stdout: string;
stderr: string;
stdoutTruncated: boolean;
stderrTruncated: boolean;
durationMs: number;
}
export interface WorkspaceResolvedPath {