@agent-api/cli 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -99,6 +99,7 @@ export function createWorkbenchCommandController(options) {
99
99
  contextEnabled: state.contextEnabled,
100
100
  defaultPreset: state.defaultPreset,
101
101
  renderMode: state.renderMode,
102
+ shellIsolation: state.shellIsolation,
102
103
  }),
103
104
  });
104
105
  return;
@@ -140,6 +141,53 @@ export function createWorkbenchCommandController(options) {
140
141
  dispatch({ type: "activity.add", level: "error", text: "Default preset save failed" });
141
142
  }
142
143
  }
144
+ if (command.field === "isolation") {
145
+ if (!command.value) {
146
+ dispatch({
147
+ type: "message.add",
148
+ role: "system",
149
+ text: options.settingsController.shellIsolationHelp(state.shellIsolation),
150
+ });
151
+ return;
152
+ }
153
+ try {
154
+ const settings = await options.settingsController.saveShellIsolationMode(command.value);
155
+ dispatch({ type: "settings.set", settings: { shellIsolation: settings.shellIsolation } });
156
+ dispatch({ type: "message.add", role: "system", text: settings.message });
157
+ dispatch({ type: "activity.add", level: "success", text: settings.activity });
158
+ }
159
+ catch (error) {
160
+ dispatch({ type: "message.add", role: "system", text: `Could not save shell isolation mode: ${userFacingError(error)}` });
161
+ dispatch({ type: "activity.add", level: "error", text: "Shell isolation save failed" });
162
+ }
163
+ return;
164
+ }
165
+ if (command.field === "isolator") {
166
+ if (!command.value) {
167
+ dispatch({
168
+ type: "message.add",
169
+ role: "system",
170
+ text: options.settingsController.isolatorPathHelp(state.shellIsolation),
171
+ });
172
+ return;
173
+ }
174
+ try {
175
+ const [subcommand = "", ...rest] = command.value.split(/\s+/);
176
+ const value = rest.join(" ").trim();
177
+ const settings = subcommand === "source"
178
+ ? await options.settingsController.saveIsolatorSource(value)
179
+ : subcommand === "path"
180
+ ? await options.settingsController.saveIsolatorPath(value)
181
+ : await options.settingsController.saveIsolatorPath(command.value);
182
+ dispatch({ type: "settings.set", settings: { shellIsolation: settings.shellIsolation } });
183
+ dispatch({ type: "message.add", role: "system", text: settings.message });
184
+ dispatch({ type: "activity.add", level: "success", text: settings.activity });
185
+ }
186
+ catch (error) {
187
+ dispatch({ type: "message.add", role: "system", text: `Could not save isolator path: ${userFacingError(error)}` });
188
+ dispatch({ type: "activity.add", level: "error", text: "Isolator path save failed" });
189
+ }
190
+ }
143
191
  }
144
192
  async function runPresetCommand(value) {
145
193
  const state = options.engine.snapshot();
@@ -0,0 +1,29 @@
1
+ export interface IsolatorInstallConfig {
2
+ sourceURL?: string | null;
3
+ executablePath?: string | null;
4
+ sha256?: string | null;
5
+ }
6
+ export interface IsolatorInstallResult {
7
+ executablePath: string;
8
+ sourceURL: string;
9
+ bytes: number;
10
+ sha256: string;
11
+ replaced: boolean;
12
+ }
13
+ export interface IsolatorEnsureResult {
14
+ executablePath: string;
15
+ sourceURL: string;
16
+ sha256?: string | null;
17
+ repaired: boolean;
18
+ }
19
+ export interface IsolatorInstallOptions {
20
+ fetchImpl?: typeof fetch;
21
+ probeTimeoutMs?: number;
22
+ }
23
+ export declare function installConfiguredIsolator(config: IsolatorInstallConfig, options?: IsolatorInstallOptions): Promise<IsolatorInstallResult>;
24
+ export declare function validateInstalledIsolator(executablePath: string, options?: Pick<IsolatorInstallOptions, "probeTimeoutMs">): Promise<string>;
25
+ export declare function ensureConfiguredIsolator(config: IsolatorInstallConfig, options?: IsolatorInstallOptions): Promise<IsolatorEnsureResult>;
26
+ export declare function relocateInstalledIsolator(fromPath: string, toPath: string, options?: Pick<IsolatorInstallOptions, "probeTimeoutMs">): Promise<string>;
27
+ export declare function defaultIsolatorInstallPath(): string;
28
+ export declare function normalizeSourceURL(value: string | null | undefined): string;
29
+ export declare function normalizeInstallPath(value: string | null | undefined): string;
@@ -0,0 +1,208 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { access, chmod, copyFile, mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
4
+ import { spawn } from "node:child_process";
5
+ import path from "node:path";
6
+ import { runtime } from "../runtime/index.js";
7
+ export async function installConfiguredIsolator(config, options = {}) {
8
+ const sourceURL = normalizeSourceURL(config.sourceURL);
9
+ const executablePath = normalizeInstallPath(config.executablePath);
10
+ const targetDir = path.dirname(executablePath);
11
+ const existing = await existingTargetState(executablePath);
12
+ await mkdir(targetDir, { recursive: true });
13
+ await ensureWritableDirectory(targetDir);
14
+ const fetchImpl = options.fetchImpl ?? fetch;
15
+ const response = await fetchImpl(sourceURL);
16
+ if (!response.ok) {
17
+ throw new Error(`isolator download failed: HTTP ${response.status}`);
18
+ }
19
+ const body = new Uint8Array(await response.arrayBuffer());
20
+ if (body.length === 0) {
21
+ throw new Error("isolator download failed: empty response body");
22
+ }
23
+ const actualSha256 = createHash("sha256").update(body).digest("hex");
24
+ const expectedSha256 = config.sha256?.trim().toLowerCase();
25
+ if (expectedSha256 && actualSha256 !== expectedSha256) {
26
+ throw new Error(`isolator checksum mismatch: expected ${expectedSha256}, got ${actualSha256}`);
27
+ }
28
+ const tempPath = path.join(targetDir, `.${path.basename(executablePath)}.${process.pid}.${Date.now()}.tmp`);
29
+ try {
30
+ await writeFile(tempPath, body, { mode: 0o700 });
31
+ if (process.platform !== "win32") {
32
+ await chmod(tempPath, 0o700);
33
+ }
34
+ await probeIsolator(tempPath, options.probeTimeoutMs ?? 10_000);
35
+ await rename(tempPath, executablePath);
36
+ }
37
+ catch (error) {
38
+ await rm(tempPath, { force: true });
39
+ throw error;
40
+ }
41
+ return {
42
+ executablePath,
43
+ sourceURL,
44
+ bytes: body.length,
45
+ sha256: actualSha256,
46
+ replaced: existing === "file",
47
+ };
48
+ }
49
+ export async function validateInstalledIsolator(executablePath, options = {}) {
50
+ const normalized = normalizeInstallPath(executablePath);
51
+ const state = await existingTargetState(normalized);
52
+ if (state !== "file") {
53
+ throw new Error(`isolator executable does not exist: ${normalized}`);
54
+ }
55
+ await probeIsolator(normalized, options.probeTimeoutMs ?? 10_000);
56
+ return normalized;
57
+ }
58
+ export async function ensureConfiguredIsolator(config, options = {}) {
59
+ const sourceURL = normalizeSourceURL(config.sourceURL);
60
+ const executablePath = normalizeInstallPath(config.executablePath);
61
+ try {
62
+ await validateInstalledIsolator(executablePath, options);
63
+ return {
64
+ executablePath,
65
+ sourceURL,
66
+ sha256: config.sha256,
67
+ repaired: false,
68
+ };
69
+ }
70
+ catch {
71
+ const result = await installConfiguredIsolator({ sourceURL, executablePath, sha256: config.sha256 }, options);
72
+ return {
73
+ executablePath: result.executablePath,
74
+ sourceURL: result.sourceURL,
75
+ sha256: result.sha256,
76
+ repaired: true,
77
+ };
78
+ }
79
+ }
80
+ export async function relocateInstalledIsolator(fromPath, toPath, options = {}) {
81
+ const source = await validateInstalledIsolator(fromPath, options);
82
+ const target = normalizeInstallPath(toPath);
83
+ if (source === target)
84
+ return target;
85
+ const targetDir = path.dirname(target);
86
+ await existingTargetState(target);
87
+ await mkdir(targetDir, { recursive: true });
88
+ await ensureWritableDirectory(targetDir);
89
+ const tempPath = path.join(targetDir, `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`);
90
+ try {
91
+ await copyFile(source, tempPath);
92
+ if (process.platform !== "win32") {
93
+ await chmod(tempPath, 0o700);
94
+ }
95
+ await probeIsolator(tempPath, options.probeTimeoutMs ?? 10_000);
96
+ await rename(tempPath, target);
97
+ }
98
+ catch (error) {
99
+ await rm(tempPath, { force: true });
100
+ throw error;
101
+ }
102
+ return target;
103
+ }
104
+ export function defaultIsolatorInstallPath() {
105
+ return path.join(runtime.dirs.data, "bin", process.platform === "win32" ? "agent-isolator.exe" : "agent-isolator");
106
+ }
107
+ export function normalizeSourceURL(value) {
108
+ const trimmed = value?.trim();
109
+ if (!trimmed)
110
+ throw new Error("isolator sourceURL is required");
111
+ let url;
112
+ try {
113
+ url = new URL(trimmed);
114
+ }
115
+ catch {
116
+ throw new Error("isolator sourceURL must be a valid URL");
117
+ }
118
+ if (url.protocol !== "https:") {
119
+ throw new Error("isolator sourceURL must use https");
120
+ }
121
+ return url.toString();
122
+ }
123
+ export function normalizeInstallPath(value) {
124
+ const trimmed = value?.trim();
125
+ if (!trimmed)
126
+ throw new Error("isolator executablePath is required");
127
+ if (!path.isAbsolute(trimmed)) {
128
+ throw new Error("isolator executablePath must be absolute");
129
+ }
130
+ const normalized = path.normalize(trimmed);
131
+ const root = path.parse(normalized).root;
132
+ if (normalized === root) {
133
+ throw new Error("isolator executablePath cannot be a filesystem root");
134
+ }
135
+ if (path.basename(normalized).startsWith(".")) {
136
+ throw new Error("isolator executablePath must not be a hidden temp-style filename");
137
+ }
138
+ return normalized;
139
+ }
140
+ async function existingTargetState(file) {
141
+ try {
142
+ const info = await stat(file);
143
+ if (!info.isFile()) {
144
+ throw new Error(`isolator executablePath exists but is not a file: ${file}`);
145
+ }
146
+ return "file";
147
+ }
148
+ catch (error) {
149
+ if (error?.code === "ENOENT")
150
+ return "missing";
151
+ throw error;
152
+ }
153
+ }
154
+ async function ensureWritableDirectory(dir) {
155
+ try {
156
+ await access(dir, fsConstants.W_OK);
157
+ }
158
+ catch {
159
+ throw new Error(`isolator target directory is not writable: ${dir}`);
160
+ }
161
+ }
162
+ async function probeIsolator(executablePath, timeoutMs) {
163
+ const request = JSON.stringify({ id: "status", method: "status", params: {} });
164
+ const child = spawn(executablePath, ["--once", "--driver=auto"], {
165
+ stdio: ["pipe", "pipe", "pipe"],
166
+ windowsHide: true,
167
+ });
168
+ const chunks = [];
169
+ const errors = [];
170
+ child.stdout.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
171
+ child.stderr.on("data", (chunk) => errors.push(Buffer.from(chunk)));
172
+ child.stdin.end(`${request}\n`);
173
+ const code = await new Promise((resolve, reject) => {
174
+ const timer = setTimeout(() => {
175
+ child.kill();
176
+ reject(new Error("isolator probe timed out"));
177
+ }, timeoutMs);
178
+ child.on("error", (error) => {
179
+ clearTimeout(timer);
180
+ reject(error);
181
+ });
182
+ child.on("close", (exitCode) => {
183
+ clearTimeout(timer);
184
+ resolve(exitCode);
185
+ });
186
+ });
187
+ const stdout = Buffer.concat(chunks).toString("utf8").trim();
188
+ const stderr = Buffer.concat(errors).toString("utf8").trim();
189
+ if (code !== 0) {
190
+ throw new Error(stderr || stdout || `isolator probe failed with exit code ${code}`);
191
+ }
192
+ const line = stdout.split(/\r?\n/).find(Boolean);
193
+ if (!line)
194
+ throw new Error("isolator probe returned no response");
195
+ let parsed;
196
+ try {
197
+ parsed = JSON.parse(line);
198
+ }
199
+ catch {
200
+ throw new Error("isolator probe returned invalid JSON");
201
+ }
202
+ if (parsed?.error) {
203
+ throw new Error(parsed.error.message || parsed.error.code || "isolator probe failed");
204
+ }
205
+ if (!parsed?.result?.status?.driver) {
206
+ throw new Error("isolator probe response did not include shell isolation status");
207
+ }
208
+ }
@@ -1,5 +1,6 @@
1
1
  import { openWorkdir } from "../workdir/index.js";
2
2
  import type { WorkbenchState, WorkbenchWorkdirStatus } from "../tui/workbench.js";
3
+ import type { ShellIsolationPreferences } from "./shell-isolation.js";
3
4
  export interface WorkbenchLocalController {
4
5
  load(path?: string): Promise<WorkbenchWorkdirStatus>;
5
6
  isLoaded(): boolean;
@@ -13,6 +14,7 @@ export interface WorkbenchLocalController {
13
14
  }
14
15
  export interface WorkbenchLocalControllerOptions {
15
16
  openWorkdirImpl?: typeof openWorkdir;
17
+ getShellIsolation?: () => ShellIsolationPreferences | undefined;
16
18
  }
17
19
  type LocalApprovalLike = NonNullable<WorkbenchState["pendingLocalTool"]>;
18
20
  export declare function createWorkbenchLocalController(options?: WorkbenchLocalControllerOptions): WorkbenchLocalController;
@@ -1,5 +1,6 @@
1
1
  import { createLocalShellToolRegistry, createLocalWorkdirToolRegistry } from "@agent-api/sdk/local";
2
2
  import { openWorkdir } from "../workdir/index.js";
3
+ import { localShellIsolationOptions } from "./shell-isolation.js";
3
4
  export function createWorkbenchLocalController(options = {}) {
4
5
  const openWorkdirImpl = options.openWorkdirImpl ?? openWorkdir;
5
6
  let workdir = null;
@@ -54,7 +55,11 @@ export function createWorkbenchLocalController(options = {}) {
54
55
  async applyApproval(approval) {
55
56
  const current = requireWorkdir();
56
57
  const workdirRegistry = createLocalWorkdirToolRegistry(current.workdir, { accessMode: "full" });
57
- const shellRegistry = createLocalShellToolRegistry({ workdir: current.workdir, accessMode: "full" });
58
+ const shellRegistry = createLocalShellToolRegistry({
59
+ workdir: current.workdir,
60
+ accessMode: "full",
61
+ ...localShellIsolationOptions(options.getShellIsolation?.()),
62
+ });
58
63
  if (approval.name === workdirRegistry.toolName) {
59
64
  return await workdirRegistry.execute(approval.name, approval.arguments);
60
65
  }
@@ -14,7 +14,9 @@ export function createWorkbenchSession(options) {
14
14
  model: options.baseOptions.model,
15
15
  preset: options.baseOptions.preset,
16
16
  });
17
- const local = createWorkbenchLocalController();
17
+ const local = createWorkbenchLocalController({
18
+ getShellIsolation: () => engine.snapshot().shellIsolation,
19
+ });
18
20
  const runtime = createWorkbenchRuntimeController({ dispatch: engine.dispatch });
19
21
  const turn = createWorkbenchTurnController({
20
22
  baseOptions: options.baseOptions,
@@ -1,9 +1,14 @@
1
1
  import { clearPresetToolCatalogCache, isAvailablePreset, listAvailablePresets, type AgentRunOptions } from "../agent.js";
2
2
  import { loadWorkbenchPreferences, updateWorkbenchPreferences, type WorkbenchPreferences } from "../config.js";
3
3
  import type { RenderMode } from "../tui/workbench.js";
4
+ import type { ShellIsolationMode, ShellIsolationPreferences } from "./shell-isolation.js";
5
+ import { type IsolatorInstallOptions } from "./isolator-installer.js";
4
6
  export interface WorkbenchSettingsSnapshot {
5
7
  defaultPreset?: string | null;
6
8
  runPreset?: string;
9
+ shellIsolation?: ShellIsolationPreferences;
10
+ activity?: string;
11
+ warning?: string;
7
12
  }
8
13
  export interface WorkbenchSettingsController {
9
14
  loadInitial(options: Pick<AgentRunOptions, "modelExplicit" | "preset" | "presetExplicit">): Promise<WorkbenchSettingsSnapshot>;
@@ -15,6 +20,18 @@ export interface WorkbenchSettingsController {
15
20
  message: string;
16
21
  activity: string;
17
22
  }>;
23
+ saveShellIsolationMode(value: string): Promise<WorkbenchSettingsSnapshot & {
24
+ message: string;
25
+ activity: string;
26
+ }>;
27
+ saveIsolatorPath(value: string): Promise<WorkbenchSettingsSnapshot & {
28
+ message: string;
29
+ activity: string;
30
+ }>;
31
+ saveIsolatorSource(value: string): Promise<WorkbenchSettingsSnapshot & {
32
+ message: string;
33
+ activity: string;
34
+ }>;
18
35
  validatePreset(profileName: string | undefined, preset: string): Promise<boolean>;
19
36
  presetListText(input: {
20
37
  profileName?: string;
@@ -29,8 +46,11 @@ export interface WorkbenchSettingsController {
29
46
  runModel?: string;
30
47
  runPreset?: string;
31
48
  renderMode: RenderMode;
49
+ shellIsolation?: ShellIsolationPreferences;
32
50
  }): string;
33
51
  defaultPresetHelp(defaultPreset?: string | null): string;
52
+ shellIsolationHelp(shellIsolation?: ShellIsolationPreferences): string;
53
+ isolatorPathHelp(shellIsolation?: ShellIsolationPreferences): string;
34
54
  clearPresetToolCatalogCache(baseURL?: string): void;
35
55
  }
36
56
  export interface WorkbenchSettingsControllerOptions {
@@ -39,6 +59,7 @@ export interface WorkbenchSettingsControllerOptions {
39
59
  isAvailablePresetImpl?: typeof isAvailablePreset;
40
60
  listAvailablePresetsImpl?: typeof listAvailablePresets;
41
61
  clearPresetToolCatalogCacheImpl?: typeof clearPresetToolCatalogCache;
62
+ isolatorInstallOptions?: IsolatorInstallOptions;
42
63
  formatError?: (error: unknown) => string;
43
64
  }
44
65
  export declare function createWorkbenchSettingsController(options?: WorkbenchSettingsControllerOptions): WorkbenchSettingsController;
@@ -48,5 +69,11 @@ export declare class UnknownPresetError extends Error {
48
69
  }
49
70
  export declare function normalizeDefaultPreset(value: string): string | null | undefined;
50
71
  export declare function formatDefaultPreset(value: string | null | undefined): string;
72
+ export declare function normalizeShellIsolationMode(value: string): ShellIsolationMode;
73
+ export declare function normalizeIsolatorPath(value: string): string | null;
74
+ export declare function normalizeIsolatorSource(value: string): string | null;
75
+ export declare function formatShellIsolation(value: ShellIsolationPreferences | undefined): ShellIsolationMode;
76
+ export declare function formatIsolatorPath(value: ShellIsolationPreferences | undefined): string;
77
+ export declare function formatIsolatorSource(value: ShellIsolationPreferences | undefined): string;
51
78
  export declare function effectiveDefaultPreset(preferences: WorkbenchPreferences, builtInPreset?: string): string | undefined;
52
79
  export declare function formatPresetList(presets: Awaited<ReturnType<typeof listAvailablePresets>>, currentPreset?: string): string[];
@@ -1,22 +1,101 @@
1
1
  import { clearPresetToolCatalogCache, isAvailablePreset, listAvailablePresets, } from "../agent.js";
2
2
  import { loadWorkbenchPreferences, updateWorkbenchPreferences, } from "../config.js";
3
+ import { ensureConfiguredIsolator, installConfiguredIsolator, normalizeSourceURL, relocateInstalledIsolator, validateInstalledIsolator, } from "./isolator-installer.js";
3
4
  export function createWorkbenchSettingsController(options = {}) {
4
5
  const loadWorkbenchPreferencesImpl = options.loadWorkbenchPreferencesImpl ?? loadWorkbenchPreferences;
5
6
  const updateWorkbenchPreferencesImpl = options.updateWorkbenchPreferencesImpl ?? updateWorkbenchPreferences;
6
7
  const isAvailablePresetImpl = options.isAvailablePresetImpl ?? isAvailablePreset;
7
8
  const listAvailablePresetsImpl = options.listAvailablePresetsImpl ?? listAvailablePresets;
8
9
  const clearPresetToolCatalogCacheImpl = options.clearPresetToolCatalogCacheImpl ?? clearPresetToolCatalogCache;
10
+ const isolatorInstallOptions = options.isolatorInstallOptions ?? {};
9
11
  const formatError = options.formatError ?? userFacingError;
10
12
  return {
11
13
  async loadInitial(agentOptions) {
12
- const preferences = await loadWorkbenchPreferencesImpl();
14
+ const loadedPreferences = await loadWorkbenchPreferencesImpl();
15
+ const { preferences, activity, warning } = await reconcileConfiguredIsolator(loadedPreferences, updateWorkbenchPreferencesImpl, isolatorInstallOptions, formatError);
13
16
  return {
14
17
  defaultPreset: preferences.defaultPreset,
18
+ ...(preferences.isolation ? { shellIsolation: preferences.isolation } : {}),
19
+ ...(activity ? { activity } : {}),
20
+ ...(warning ? { warning } : {}),
15
21
  runPreset: shouldApplyDefaultPreset(agentOptions)
16
22
  ? effectiveDefaultPreset(preferences, agentOptions.preset)
17
23
  : undefined,
18
24
  };
19
25
  },
26
+ async saveShellIsolationMode(value) {
27
+ const mode = normalizeShellIsolationMode(value);
28
+ const preferences = await updateWorkbenchPreferencesImpl({ isolation: { mode } });
29
+ return {
30
+ ...settingsSnapshot(preferences),
31
+ message: `Saved shell isolation mode: ${formatShellIsolation(preferences.isolation)}.`,
32
+ activity: `Shell isolation mode saved: ${preferences.isolation?.mode ?? "auto"}`,
33
+ };
34
+ },
35
+ async saveIsolatorPath(value) {
36
+ const executablePath = normalizeIsolatorPath(value);
37
+ if (!executablePath) {
38
+ const preferences = await updateWorkbenchPreferencesImpl({ isolation: { executablePath } });
39
+ return {
40
+ ...settingsSnapshot(preferences),
41
+ message: `Saved isolator path: ${formatIsolatorPath(preferences.isolation)}.`,
42
+ activity: "Isolator path cleared",
43
+ };
44
+ }
45
+ const current = (await loadWorkbenchPreferencesImpl()).isolation;
46
+ if (current?.sourceURL) {
47
+ const result = await installConfiguredIsolator({
48
+ sourceURL: current.sourceURL,
49
+ executablePath,
50
+ sha256: current.sha256,
51
+ }, isolatorInstallOptions);
52
+ const preferences = await updateWorkbenchPreferencesImpl({
53
+ isolation: { executablePath: result.executablePath, sourceURL: result.sourceURL, sha256: result.sha256, installSkipped: false },
54
+ });
55
+ return {
56
+ ...settingsSnapshot(preferences),
57
+ message: `Installed isolator to ${result.executablePath}.`,
58
+ activity: result.replaced ? "Isolator refreshed" : "Isolator installed",
59
+ };
60
+ }
61
+ const validatedPath = current?.executablePath
62
+ ? await relocateInstalledIsolator(current.executablePath, executablePath, isolatorInstallOptions)
63
+ : await validateInstalledIsolator(executablePath, isolatorInstallOptions);
64
+ const preferences = await updateWorkbenchPreferencesImpl({ isolation: { executablePath: validatedPath, installSkipped: false } });
65
+ return {
66
+ ...settingsSnapshot(preferences),
67
+ message: `Saved verified isolator path: ${formatIsolatorPath(preferences.isolation)}.`,
68
+ activity: "Isolator path verified",
69
+ };
70
+ },
71
+ async saveIsolatorSource(value) {
72
+ const sourceURL = normalizeIsolatorSource(value);
73
+ const current = (await loadWorkbenchPreferencesImpl()).isolation;
74
+ if (!sourceURL) {
75
+ const preferences = await updateWorkbenchPreferencesImpl({ isolation: { sourceURL, sha256: null } });
76
+ return {
77
+ ...settingsSnapshot(preferences),
78
+ message: "Cleared isolator source URL.",
79
+ activity: "Isolator source cleared",
80
+ };
81
+ }
82
+ if (!current?.executablePath) {
83
+ throw new Error("Set a verified isolator path before saving a source URL.");
84
+ }
85
+ const result = await installConfiguredIsolator({
86
+ sourceURL,
87
+ executablePath: current.executablePath,
88
+ sha256: current.sha256,
89
+ }, isolatorInstallOptions);
90
+ const preferences = await updateWorkbenchPreferencesImpl({
91
+ isolation: { sourceURL: result.sourceURL, executablePath: result.executablePath, sha256: result.sha256, installSkipped: false },
92
+ });
93
+ return {
94
+ ...settingsSnapshot(preferences),
95
+ message: `Installed isolator from ${result.sourceURL}.`,
96
+ activity: result.replaced ? "Isolator refreshed from source" : "Isolator installed from source",
97
+ };
98
+ },
20
99
  async saveDefaultPreset(input) {
21
100
  const normalized = normalizeDefaultPreset(input.value);
22
101
  if (typeof normalized === "string" && !(await isAvailablePresetImpl(input.profileName, normalized))) {
@@ -25,6 +104,7 @@ export function createWorkbenchSettingsController(options = {}) {
25
104
  const preferences = await updateWorkbenchPreferencesImpl({ defaultPreset: normalized });
26
105
  return {
27
106
  defaultPreset: preferences.defaultPreset,
107
+ ...(preferences.isolation ? { shellIsolation: preferences.isolation } : {}),
28
108
  runPreset: shouldApplyDefaultPreset(input.options)
29
109
  ? effectiveDefaultPreset(preferences, input.options.preset)
30
110
  : undefined,
@@ -57,6 +137,16 @@ export function createWorkbenchSettingsController(options = {}) {
57
137
  defaultPresetHelp(defaultPreset) {
58
138
  return `Default preset: ${formatDefaultPreset(defaultPreset)}. Use /config preset <name>, /config preset none, or /config preset reset.`;
59
139
  },
140
+ shellIsolationHelp(shellIsolation) {
141
+ return `Shell isolation: ${formatShellIsolation(shellIsolation)}. Use /config isolation none, /config isolation auto, or /config isolation required.`;
142
+ },
143
+ isolatorPathHelp(shellIsolation) {
144
+ return [
145
+ `Isolator path: ${formatIsolatorPath(shellIsolation)}`,
146
+ `Isolator source: ${formatIsolatorSource(shellIsolation)}`,
147
+ "Use /config isolator path <absolute-path>, /config isolator source <https-url>, or /config isolator none to clear the path.",
148
+ ].join("\n");
149
+ },
60
150
  clearPresetToolCatalogCache(baseURL) {
61
151
  clearPresetToolCatalogCacheImpl(baseURL);
62
152
  },
@@ -84,6 +174,49 @@ export function formatDefaultPreset(value) {
84
174
  return "built-in (pro-search)";
85
175
  return value ?? "none";
86
176
  }
177
+ function settingsSnapshot(preferences) {
178
+ return {
179
+ ...("defaultPreset" in preferences ? { defaultPreset: preferences.defaultPreset } : {}),
180
+ ...(preferences.isolation ? { shellIsolation: preferences.isolation } : {}),
181
+ };
182
+ }
183
+ export function normalizeShellIsolationMode(value) {
184
+ const lowered = value.trim().toLowerCase();
185
+ if (lowered === "none" || lowered === "off" || lowered === "disable" || lowered === "disabled")
186
+ return "none";
187
+ if (lowered === "auto" || lowered === "default")
188
+ return "auto";
189
+ if (lowered === "required" || lowered === "require" || lowered === "strict")
190
+ return "required";
191
+ throw new Error("Unknown shell isolation mode. Use none, auto, or required.");
192
+ }
193
+ export function normalizeIsolatorPath(value) {
194
+ const trimmed = value.trim();
195
+ if (!trimmed)
196
+ throw new Error("Usage: /config isolator <path>, or /config isolator none.");
197
+ const lowered = trimmed.toLowerCase();
198
+ if (["none", "off", "disable", "disabled", "clear", "reset"].includes(lowered))
199
+ return null;
200
+ return trimmed;
201
+ }
202
+ export function normalizeIsolatorSource(value) {
203
+ const trimmed = value.trim();
204
+ if (!trimmed)
205
+ throw new Error("Usage: /config isolator source <https-url>, or /config isolator source none.");
206
+ const lowered = trimmed.toLowerCase();
207
+ if (["none", "off", "disable", "disabled", "clear", "reset"].includes(lowered))
208
+ return null;
209
+ return normalizeSourceURL(trimmed);
210
+ }
211
+ export function formatShellIsolation(value) {
212
+ return value?.mode ?? "auto";
213
+ }
214
+ export function formatIsolatorPath(value) {
215
+ return value?.executablePath || process.env.AGENT_ISOLATOR_PATH || "not configured";
216
+ }
217
+ export function formatIsolatorSource(value) {
218
+ return value?.sourceURL || "not configured";
219
+ }
87
220
  export function effectiveDefaultPreset(preferences, builtInPreset) {
88
221
  if ("defaultPreset" in preferences)
89
222
  return preferences.defaultPreset ?? undefined;
@@ -101,13 +234,16 @@ export function formatPresetList(presets, currentPreset) {
101
234
  function shouldApplyDefaultPreset(options) {
102
235
  return !options.presetExplicit && !options.modelExplicit;
103
236
  }
104
- function runConfigText({ accessMode, contextEnabled, defaultPreset, profileName, runModel, runPreset, renderMode, }) {
237
+ function runConfigText({ accessMode, contextEnabled, defaultPreset, profileName, runModel, runPreset, renderMode, shellIsolation, }) {
105
238
  return [
106
239
  `Profile: ${profileName}`,
107
240
  `Preset: ${runPreset || "none"}`,
108
241
  `Default preset: ${formatDefaultPreset(defaultPreset)}`,
109
242
  `Model: ${runModel || "auto"}`,
110
243
  `Render mode: ${renderMode}`,
244
+ `Shell isolation: ${formatShellIsolation(shellIsolation)}`,
245
+ `Isolator path: ${formatIsolatorPath(shellIsolation)}`,
246
+ `Isolator source: ${formatIsolatorSource(shellIsolation)}`,
111
247
  `local_workdir tool: ${contextEnabled ? "on" : "off"}`,
112
248
  `local_shell tool: ${contextEnabled ? "on" : "off"}`,
113
249
  `Local access: ${accessMode}`,
@@ -118,3 +254,35 @@ function userFacingError(error) {
118
254
  return error.message;
119
255
  return String(error);
120
256
  }
257
+ async function reconcileConfiguredIsolator(preferences, updatePreferences, installOptions, formatError) {
258
+ const isolation = preferences.isolation;
259
+ if (!isolation?.sourceURL || !isolation.executablePath)
260
+ return { preferences };
261
+ try {
262
+ const result = await ensureConfiguredIsolator({
263
+ sourceURL: isolation.sourceURL,
264
+ executablePath: isolation.executablePath,
265
+ sha256: isolation.sha256,
266
+ }, installOptions);
267
+ if (!result.repaired)
268
+ return { preferences };
269
+ const updated = await updatePreferences({
270
+ isolation: {
271
+ sourceURL: result.sourceURL,
272
+ executablePath: result.executablePath,
273
+ sha256: result.sha256,
274
+ installSkipped: false,
275
+ },
276
+ });
277
+ return {
278
+ preferences: updated,
279
+ activity: `Reinstalled isolator: ${result.executablePath}`,
280
+ };
281
+ }
282
+ catch (error) {
283
+ return {
284
+ preferences,
285
+ warning: `Configured isolator is unavailable: ${formatError(error)}`,
286
+ };
287
+ }
288
+ }
@@ -0,0 +1,20 @@
1
+ export type ShellIsolationMode = "none" | "auto" | "required";
2
+ export interface ShellIsolationPreferences {
3
+ mode?: ShellIsolationMode;
4
+ executablePath?: string | null;
5
+ version?: string | null;
6
+ sourceURL?: string | null;
7
+ sha256?: string | null;
8
+ installSkipped?: boolean | null;
9
+ }
10
+ export declare function localShellIsolationOptions(preferences?: ShellIsolationPreferences): {
11
+ readonly isolator?: {
12
+ executablePath: string;
13
+ } | undefined;
14
+ readonly isolation: ShellIsolationMode;
15
+ readonly isolationOptions: {
16
+ readonly filesystem: "workdir-readwrite";
17
+ readonly network: "allowed";
18
+ readonly env: "inherit";
19
+ };
20
+ };