@agent-api/cli 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,15 @@
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
+ notice?: string;
12
+ warning?: string;
7
13
  }
8
14
  export interface WorkbenchSettingsController {
9
15
  loadInitial(options: Pick<AgentRunOptions, "modelExplicit" | "preset" | "presetExplicit">): Promise<WorkbenchSettingsSnapshot>;
@@ -15,6 +21,18 @@ export interface WorkbenchSettingsController {
15
21
  message: string;
16
22
  activity: string;
17
23
  }>;
24
+ saveShellIsolationMode(value: string): Promise<WorkbenchSettingsSnapshot & {
25
+ message: string;
26
+ activity: string;
27
+ }>;
28
+ saveIsolatorPath(value: string): Promise<WorkbenchSettingsSnapshot & {
29
+ message: string;
30
+ activity: string;
31
+ }>;
32
+ saveIsolatorSource(value: string): Promise<WorkbenchSettingsSnapshot & {
33
+ message: string;
34
+ activity: string;
35
+ }>;
18
36
  validatePreset(profileName: string | undefined, preset: string): Promise<boolean>;
19
37
  presetListText(input: {
20
38
  profileName?: string;
@@ -29,8 +47,11 @@ export interface WorkbenchSettingsController {
29
47
  runModel?: string;
30
48
  runPreset?: string;
31
49
  renderMode: RenderMode;
50
+ shellIsolation?: ShellIsolationPreferences;
32
51
  }): string;
33
52
  defaultPresetHelp(defaultPreset?: string | null): string;
53
+ shellIsolationHelp(shellIsolation?: ShellIsolationPreferences): string;
54
+ isolatorPathHelp(shellIsolation?: ShellIsolationPreferences): string;
34
55
  clearPresetToolCatalogCache(baseURL?: string): void;
35
56
  }
36
57
  export interface WorkbenchSettingsControllerOptions {
@@ -39,6 +60,7 @@ export interface WorkbenchSettingsControllerOptions {
39
60
  isAvailablePresetImpl?: typeof isAvailablePreset;
40
61
  listAvailablePresetsImpl?: typeof listAvailablePresets;
41
62
  clearPresetToolCatalogCacheImpl?: typeof clearPresetToolCatalogCache;
63
+ isolatorInstallOptions?: IsolatorInstallOptions;
42
64
  formatError?: (error: unknown) => string;
43
65
  }
44
66
  export declare function createWorkbenchSettingsController(options?: WorkbenchSettingsControllerOptions): WorkbenchSettingsController;
@@ -48,5 +70,11 @@ export declare class UnknownPresetError extends Error {
48
70
  }
49
71
  export declare function normalizeDefaultPreset(value: string): string | null | undefined;
50
72
  export declare function formatDefaultPreset(value: string | null | undefined): string;
73
+ export declare function normalizeShellIsolationMode(value: string): ShellIsolationMode;
74
+ export declare function normalizeIsolatorPath(value: string): string | null;
75
+ export declare function normalizeIsolatorSource(value: string): string | null;
76
+ export declare function formatShellIsolation(value: ShellIsolationPreferences | undefined): ShellIsolationMode;
77
+ export declare function formatIsolatorPath(value: ShellIsolationPreferences | undefined): string;
78
+ export declare function formatIsolatorSource(value: ShellIsolationPreferences | undefined): string;
51
79
  export declare function effectiveDefaultPreset(preferences: WorkbenchPreferences, builtInPreset?: string): string | undefined;
52
80
  export declare function formatPresetList(presets: Awaited<ReturnType<typeof listAvailablePresets>>, currentPreset?: string): string[];
@@ -1,22 +1,107 @@
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
+ ...(isolatorSetupNotice(preferences.isolation) ? { notice: isolatorSetupNotice(preferences.isolation) } : {}),
21
+ ...(warning ? { warning } : {}),
15
22
  runPreset: shouldApplyDefaultPreset(agentOptions)
16
23
  ? effectiveDefaultPreset(preferences, agentOptions.preset)
17
24
  : undefined,
18
25
  };
19
26
  },
27
+ async saveShellIsolationMode(value) {
28
+ const mode = normalizeShellIsolationMode(value);
29
+ const preferences = await updateWorkbenchPreferencesImpl({
30
+ isolation: {
31
+ mode,
32
+ installSkipped: mode === "none" ? true : false,
33
+ },
34
+ });
35
+ return {
36
+ ...settingsSnapshot(preferences),
37
+ message: `Saved shell isolation mode: ${formatShellIsolation(preferences.isolation)}.`,
38
+ activity: `Shell isolation mode saved: ${preferences.isolation?.mode ?? "auto"}`,
39
+ };
40
+ },
41
+ async saveIsolatorPath(value) {
42
+ const executablePath = normalizeIsolatorPath(value);
43
+ if (!executablePath) {
44
+ const preferences = await updateWorkbenchPreferencesImpl({ isolation: { executablePath } });
45
+ return {
46
+ ...settingsSnapshot(preferences),
47
+ message: `Saved isolator path: ${formatIsolatorPath(preferences.isolation)}.`,
48
+ activity: "Isolator path cleared",
49
+ };
50
+ }
51
+ const current = (await loadWorkbenchPreferencesImpl()).isolation;
52
+ if (current?.sourceURL) {
53
+ const result = await installConfiguredIsolator({
54
+ sourceURL: current.sourceURL,
55
+ executablePath,
56
+ sha256: current.sha256,
57
+ }, isolatorInstallOptions);
58
+ const preferences = await updateWorkbenchPreferencesImpl({
59
+ isolation: { executablePath: result.executablePath, sourceURL: result.sourceURL, sha256: result.sha256, installSkipped: false },
60
+ });
61
+ return {
62
+ ...settingsSnapshot(preferences),
63
+ message: `Installed isolator to ${result.executablePath}.`,
64
+ activity: result.replaced ? "Isolator refreshed" : "Isolator installed",
65
+ };
66
+ }
67
+ const validatedPath = current?.executablePath
68
+ ? await relocateInstalledIsolator(current.executablePath, executablePath, isolatorInstallOptions)
69
+ : await validateInstalledIsolator(executablePath, isolatorInstallOptions);
70
+ const preferences = await updateWorkbenchPreferencesImpl({ isolation: { executablePath: validatedPath, installSkipped: false } });
71
+ return {
72
+ ...settingsSnapshot(preferences),
73
+ message: `Saved verified isolator path: ${formatIsolatorPath(preferences.isolation)}.`,
74
+ activity: "Isolator path verified",
75
+ };
76
+ },
77
+ async saveIsolatorSource(value) {
78
+ const sourceURL = normalizeIsolatorSource(value);
79
+ const current = (await loadWorkbenchPreferencesImpl()).isolation;
80
+ if (!sourceURL) {
81
+ const preferences = await updateWorkbenchPreferencesImpl({ isolation: { sourceURL, sha256: null } });
82
+ return {
83
+ ...settingsSnapshot(preferences),
84
+ message: "Cleared isolator source URL.",
85
+ activity: "Isolator source cleared",
86
+ };
87
+ }
88
+ if (!current?.executablePath) {
89
+ throw new Error("Set a verified isolator path before saving a source URL.");
90
+ }
91
+ const result = await installConfiguredIsolator({
92
+ sourceURL,
93
+ executablePath: current.executablePath,
94
+ sha256: current.sha256,
95
+ }, isolatorInstallOptions);
96
+ const preferences = await updateWorkbenchPreferencesImpl({
97
+ isolation: { sourceURL: result.sourceURL, executablePath: result.executablePath, sha256: result.sha256, installSkipped: false },
98
+ });
99
+ return {
100
+ ...settingsSnapshot(preferences),
101
+ message: `Installed isolator from ${result.sourceURL}.`,
102
+ activity: result.replaced ? "Isolator refreshed from source" : "Isolator installed from source",
103
+ };
104
+ },
20
105
  async saveDefaultPreset(input) {
21
106
  const normalized = normalizeDefaultPreset(input.value);
22
107
  if (typeof normalized === "string" && !(await isAvailablePresetImpl(input.profileName, normalized))) {
@@ -25,6 +110,7 @@ export function createWorkbenchSettingsController(options = {}) {
25
110
  const preferences = await updateWorkbenchPreferencesImpl({ defaultPreset: normalized });
26
111
  return {
27
112
  defaultPreset: preferences.defaultPreset,
113
+ ...(preferences.isolation ? { shellIsolation: preferences.isolation } : {}),
28
114
  runPreset: shouldApplyDefaultPreset(input.options)
29
115
  ? effectiveDefaultPreset(preferences, input.options.preset)
30
116
  : undefined,
@@ -57,6 +143,16 @@ export function createWorkbenchSettingsController(options = {}) {
57
143
  defaultPresetHelp(defaultPreset) {
58
144
  return `Default preset: ${formatDefaultPreset(defaultPreset)}. Use /config preset <name>, /config preset none, or /config preset reset.`;
59
145
  },
146
+ shellIsolationHelp(shellIsolation) {
147
+ return `Shell isolation: ${formatShellIsolation(shellIsolation)}. Use /config isolation none, /config isolation auto, or /config isolation required.`;
148
+ },
149
+ isolatorPathHelp(shellIsolation) {
150
+ return [
151
+ `Isolator path: ${formatIsolatorPath(shellIsolation)}`,
152
+ `Isolator source: ${formatIsolatorSource(shellIsolation)}`,
153
+ "Use /config isolator path <absolute-path>, /config isolator source <https-url>, or /config isolator none to clear the path.",
154
+ ].join("\n");
155
+ },
60
156
  clearPresetToolCatalogCache(baseURL) {
61
157
  clearPresetToolCatalogCacheImpl(baseURL);
62
158
  },
@@ -84,6 +180,49 @@ export function formatDefaultPreset(value) {
84
180
  return "built-in (pro-search)";
85
181
  return value ?? "none";
86
182
  }
183
+ function settingsSnapshot(preferences) {
184
+ return {
185
+ ...("defaultPreset" in preferences ? { defaultPreset: preferences.defaultPreset } : {}),
186
+ ...(preferences.isolation ? { shellIsolation: preferences.isolation } : {}),
187
+ };
188
+ }
189
+ export function normalizeShellIsolationMode(value) {
190
+ const lowered = value.trim().toLowerCase();
191
+ if (lowered === "none" || lowered === "off" || lowered === "disable" || lowered === "disabled")
192
+ return "none";
193
+ if (lowered === "auto" || lowered === "default")
194
+ return "auto";
195
+ if (lowered === "required" || lowered === "require" || lowered === "strict")
196
+ return "required";
197
+ throw new Error("Unknown shell isolation mode. Use none, auto, or required.");
198
+ }
199
+ export function normalizeIsolatorPath(value) {
200
+ const trimmed = value.trim();
201
+ if (!trimmed)
202
+ throw new Error("Usage: /config isolator <path>, or /config isolator none.");
203
+ const lowered = trimmed.toLowerCase();
204
+ if (["none", "off", "disable", "disabled", "clear", "reset"].includes(lowered))
205
+ return null;
206
+ return trimmed;
207
+ }
208
+ export function normalizeIsolatorSource(value) {
209
+ const trimmed = value.trim();
210
+ if (!trimmed)
211
+ throw new Error("Usage: /config isolator source <https-url>, or /config isolator source none.");
212
+ const lowered = trimmed.toLowerCase();
213
+ if (["none", "off", "disable", "disabled", "clear", "reset"].includes(lowered))
214
+ return null;
215
+ return normalizeSourceURL(trimmed);
216
+ }
217
+ export function formatShellIsolation(value) {
218
+ return value?.mode ?? "auto";
219
+ }
220
+ export function formatIsolatorPath(value) {
221
+ return value?.executablePath || process.env.AGENT_ISOLATOR_PATH || "not configured";
222
+ }
223
+ export function formatIsolatorSource(value) {
224
+ return value?.sourceURL || "not configured";
225
+ }
87
226
  export function effectiveDefaultPreset(preferences, builtInPreset) {
88
227
  if ("defaultPreset" in preferences)
89
228
  return preferences.defaultPreset ?? undefined;
@@ -101,13 +240,16 @@ export function formatPresetList(presets, currentPreset) {
101
240
  function shouldApplyDefaultPreset(options) {
102
241
  return !options.presetExplicit && !options.modelExplicit;
103
242
  }
104
- function runConfigText({ accessMode, contextEnabled, defaultPreset, profileName, runModel, runPreset, renderMode, }) {
243
+ function runConfigText({ accessMode, contextEnabled, defaultPreset, profileName, runModel, runPreset, renderMode, shellIsolation, }) {
105
244
  return [
106
245
  `Profile: ${profileName}`,
107
246
  `Preset: ${runPreset || "none"}`,
108
247
  `Default preset: ${formatDefaultPreset(defaultPreset)}`,
109
248
  `Model: ${runModel || "auto"}`,
110
249
  `Render mode: ${renderMode}`,
250
+ `Shell isolation: ${formatShellIsolation(shellIsolation)}`,
251
+ `Isolator path: ${formatIsolatorPath(shellIsolation)}`,
252
+ `Isolator source: ${formatIsolatorSource(shellIsolation)}`,
111
253
  `local_workdir tool: ${contextEnabled ? "on" : "off"}`,
112
254
  `local_shell tool: ${contextEnabled ? "on" : "off"}`,
113
255
  `Local access: ${accessMode}`,
@@ -118,3 +260,50 @@ function userFacingError(error) {
118
260
  return error.message;
119
261
  return String(error);
120
262
  }
263
+ function isolatorSetupNotice(shellIsolation) {
264
+ if (process.env.AGENT_ISOLATOR_PATH)
265
+ return "";
266
+ if (shellIsolation?.installSkipped || shellIsolation?.mode === "none" || shellIsolation?.executablePath)
267
+ return "";
268
+ return [
269
+ "Local shell isolation is not configured yet.",
270
+ "",
271
+ "To enable it, set a verified target path and downloadable source URL:",
272
+ "/config isolator path <absolute-path>",
273
+ "/config isolator source <https-url>",
274
+ "",
275
+ "To skip this setup for future starts, use /config isolation none.",
276
+ ].join("\n");
277
+ }
278
+ async function reconcileConfiguredIsolator(preferences, updatePreferences, installOptions, formatError) {
279
+ const isolation = preferences.isolation;
280
+ if (!isolation?.sourceURL || !isolation.executablePath)
281
+ return { preferences };
282
+ try {
283
+ const result = await ensureConfiguredIsolator({
284
+ sourceURL: isolation.sourceURL,
285
+ executablePath: isolation.executablePath,
286
+ sha256: isolation.sha256,
287
+ }, installOptions);
288
+ if (!result.repaired)
289
+ return { preferences };
290
+ const updated = await updatePreferences({
291
+ isolation: {
292
+ sourceURL: result.sourceURL,
293
+ executablePath: result.executablePath,
294
+ sha256: result.sha256,
295
+ installSkipped: false,
296
+ },
297
+ });
298
+ return {
299
+ preferences: updated,
300
+ activity: `Reinstalled isolator: ${result.executablePath}`,
301
+ };
302
+ }
303
+ catch (error) {
304
+ return {
305
+ preferences,
306
+ warning: `Configured isolator is unavailable: ${formatError(error)}`,
307
+ };
308
+ }
309
+ }