opencode-landstrip 0.3.12 → 0.14.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.
- package/README.md +14 -8
- package/index.ts +107 -316
- package/landstrip.d.ts +1 -1
- package/package.json +9 -8
- package/shared.ts +318 -0
- package/tui.ts +171 -328
package/tui.ts
CHANGED
|
@@ -1,230 +1,35 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
// Copyright (C) Jarkko Sakkinen 2026
|
|
3
3
|
|
|
4
|
-
import type { TuiPlugin } from '@opencode-ai/plugin/tui';
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
network: SandboxNetworkConfig;
|
|
31
|
-
filesystem: SandboxFilesystemConfig;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
interface SandboxConfigOverrides {
|
|
35
|
-
enabled?: boolean;
|
|
36
|
-
network?: Partial<SandboxNetworkConfig>;
|
|
37
|
-
filesystem?: Partial<SandboxFilesystemConfig>;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const DEFAULT_CONFIG: SandboxConfig = {
|
|
41
|
-
enabled: true,
|
|
42
|
-
network: {
|
|
43
|
-
allowNetwork: false,
|
|
44
|
-
allowLocalBinding: false,
|
|
45
|
-
allowAllUnixSockets: false,
|
|
46
|
-
allowUnixSockets: [],
|
|
47
|
-
allowedDomains: [],
|
|
48
|
-
deniedDomains: [],
|
|
49
|
-
},
|
|
50
|
-
filesystem: {
|
|
51
|
-
denyRead: ['/Users', '/home'],
|
|
52
|
-
allowRead: ['.', '~/.gitconfig', '/dev/null'],
|
|
53
|
-
allowWrite: ['.', '/dev/null'],
|
|
54
|
-
denyWrite: ['**/.env', '**/.env.*', '**/*.pem', '**/*.key'],
|
|
55
|
-
},
|
|
56
|
-
};
|
|
57
|
-
const LANDSTRIP_PACKAGE_NAMES = new Set([
|
|
58
|
-
'@jarkkojs/landstrip',
|
|
59
|
-
'@jarkkojs/landstrip-darwin-arm64',
|
|
60
|
-
'@jarkkojs/landstrip-darwin-x64',
|
|
61
|
-
'@jarkkojs/landstrip-linux-x64',
|
|
62
|
-
'@jarkkojs/landstrip-win32-x64',
|
|
63
|
-
]);
|
|
64
|
-
|
|
65
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
66
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function stringArray(value: unknown): string[] | undefined {
|
|
70
|
-
if (!Array.isArray(value)) return undefined;
|
|
71
|
-
return value.every((item) => typeof item === 'string') ? [...value] : undefined;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function normalizeNetworkConfig(value: unknown): Partial<SandboxNetworkConfig> | undefined {
|
|
75
|
-
if (!isRecord(value)) return undefined;
|
|
76
|
-
|
|
77
|
-
const config: Partial<SandboxNetworkConfig> = {};
|
|
78
|
-
if (typeof value.allowNetwork === 'boolean') config.allowNetwork = value.allowNetwork;
|
|
79
|
-
if (typeof value.allowLocalBinding === 'boolean')
|
|
80
|
-
config.allowLocalBinding = value.allowLocalBinding;
|
|
81
|
-
if (typeof value.allowAllUnixSockets === 'boolean')
|
|
82
|
-
config.allowAllUnixSockets = value.allowAllUnixSockets;
|
|
83
|
-
|
|
84
|
-
const allowUnixSockets = stringArray(value.allowUnixSockets);
|
|
85
|
-
if (allowUnixSockets) config.allowUnixSockets = allowUnixSockets;
|
|
86
|
-
|
|
87
|
-
const allowedDomains = stringArray(value.allowedDomains);
|
|
88
|
-
if (allowedDomains) config.allowedDomains = allowedDomains;
|
|
89
|
-
|
|
90
|
-
const deniedDomains = stringArray(value.deniedDomains);
|
|
91
|
-
if (deniedDomains) config.deniedDomains = deniedDomains;
|
|
92
|
-
|
|
93
|
-
return config;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function normalizeFilesystemConfig(value: unknown): Partial<SandboxFilesystemConfig> | undefined {
|
|
97
|
-
if (!isRecord(value)) return undefined;
|
|
98
|
-
|
|
99
|
-
const config: Partial<SandboxFilesystemConfig> = {};
|
|
100
|
-
const denyRead = stringArray(value.denyRead);
|
|
101
|
-
if (denyRead) config.denyRead = denyRead;
|
|
102
|
-
|
|
103
|
-
const allowRead = stringArray(value.allowRead);
|
|
104
|
-
if (allowRead) config.allowRead = allowRead;
|
|
105
|
-
|
|
106
|
-
const allowWrite = stringArray(value.allowWrite);
|
|
107
|
-
if (allowWrite) config.allowWrite = allowWrite;
|
|
108
|
-
|
|
109
|
-
const denyWrite = stringArray(value.denyWrite);
|
|
110
|
-
if (denyWrite) config.denyWrite = denyWrite;
|
|
111
|
-
|
|
112
|
-
return config;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function normalizeConfig(value: unknown): SandboxConfigOverrides {
|
|
116
|
-
if (!isRecord(value)) return {};
|
|
117
|
-
|
|
118
|
-
const config: SandboxConfigOverrides = {};
|
|
119
|
-
if (typeof value.enabled === 'boolean') config.enabled = value.enabled;
|
|
120
|
-
|
|
121
|
-
const network = normalizeNetworkConfig(value.network);
|
|
122
|
-
if (network) config.network = network;
|
|
123
|
-
|
|
124
|
-
const filesystem = normalizeFilesystemConfig(value.filesystem);
|
|
125
|
-
if (filesystem) config.filesystem = filesystem;
|
|
126
|
-
|
|
127
|
-
return config;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function normalizeOptions(options: unknown): SandboxConfigOverrides {
|
|
131
|
-
if (!isRecord(options)) return {};
|
|
132
|
-
return normalizeConfig(isRecord(options.config) ? options.config : options);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function mergeArray(base: string[], override?: string[]): string[] {
|
|
136
|
-
if (!override) return base;
|
|
137
|
-
return [...new Set([...base, ...override])];
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): SandboxConfig {
|
|
141
|
-
const network = overrides.network;
|
|
142
|
-
const filesystem = overrides.filesystem;
|
|
143
|
-
|
|
144
|
-
return {
|
|
145
|
-
enabled: overrides.enabled ?? base.enabled,
|
|
146
|
-
network: {
|
|
147
|
-
allowNetwork: network?.allowNetwork ?? base.network.allowNetwork,
|
|
148
|
-
allowLocalBinding: network?.allowLocalBinding ?? base.network.allowLocalBinding,
|
|
149
|
-
allowAllUnixSockets: network?.allowAllUnixSockets ?? base.network.allowAllUnixSockets,
|
|
150
|
-
allowUnixSockets: mergeArray(base.network.allowUnixSockets, network?.allowUnixSockets),
|
|
151
|
-
allowedDomains: mergeArray(base.network.allowedDomains, network?.allowedDomains),
|
|
152
|
-
deniedDomains: mergeArray(base.network.deniedDomains, network?.deniedDomains),
|
|
153
|
-
},
|
|
154
|
-
filesystem: {
|
|
155
|
-
denyRead: mergeArray(base.filesystem.denyRead, filesystem?.denyRead),
|
|
156
|
-
allowRead: mergeArray(base.filesystem.allowRead, filesystem?.allowRead),
|
|
157
|
-
allowWrite: mergeArray(base.filesystem.allowWrite, filesystem?.allowWrite),
|
|
158
|
-
denyWrite: mergeArray(base.filesystem.denyWrite, filesystem?.denyWrite),
|
|
159
|
-
},
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function getConfigPaths(baseDirectory: string): { globalPath: string; projectPath: string } {
|
|
164
|
-
return {
|
|
165
|
-
globalPath: join(homedir(), '.config', 'opencode', 'sandbox.json'),
|
|
166
|
-
projectPath: join(baseDirectory, '.opencode', 'sandbox.json'),
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function readConfigFile(configPath: string): SandboxConfigOverrides | null {
|
|
171
|
-
if (!existsSync(configPath)) return {};
|
|
172
|
-
|
|
173
|
-
try {
|
|
174
|
-
return normalizeConfig(JSON.parse(readFileSync(configPath, 'utf-8')));
|
|
175
|
-
} catch {
|
|
176
|
-
return null;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function landstripBinaryPath(): string {
|
|
181
|
-
const filePath = realpathSync.native(binaryPath());
|
|
182
|
-
let probe = dirname(filePath);
|
|
183
|
-
|
|
184
|
-
while (true) {
|
|
185
|
-
const manifestPath = join(probe, 'package.json');
|
|
186
|
-
if (existsSync(manifestPath)) {
|
|
187
|
-
try {
|
|
188
|
-
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as unknown;
|
|
189
|
-
if (isRecord(manifest) && LANDSTRIP_PACKAGE_NAMES.has(String(manifest.name))) {
|
|
190
|
-
return filePath;
|
|
191
|
-
}
|
|
192
|
-
} catch {
|
|
193
|
-
// malformed package.json — continue walking to parent
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const parent = dirname(probe);
|
|
198
|
-
if (parent === probe) break;
|
|
199
|
-
probe = parent;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
throw new Error(
|
|
203
|
-
`Refusing to use landstrip binary outside official @jarkkojs/landstrip packages: ${filePath}`,
|
|
204
|
-
);
|
|
4
|
+
import type { TuiPlugin, TuiSlotContext, TuiSlotPlugin } from '@opencode-ai/plugin/tui';
|
|
5
|
+
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
type SandboxConfigOverrides,
|
|
10
|
+
getConfigPaths,
|
|
11
|
+
landstripBinaryPath,
|
|
12
|
+
loadConfig,
|
|
13
|
+
normalizeOptions,
|
|
14
|
+
permissionLabel,
|
|
15
|
+
permissionResource,
|
|
16
|
+
updateForPermission,
|
|
17
|
+
writeConfigFile,
|
|
18
|
+
} from './shared.js';
|
|
19
|
+
|
|
20
|
+
// The shape shared by the `permission.asked` event payload and the entries
|
|
21
|
+
// returned from `api.state.session.permission()`. Both carry `permission`
|
|
22
|
+
// (the kind), `patterns`, and `tool.callID`; neither carries a `title`.
|
|
23
|
+
interface PendingPermission {
|
|
24
|
+
id: string;
|
|
25
|
+
sessionID: string;
|
|
26
|
+
permission: string;
|
|
27
|
+
patterns: string[];
|
|
28
|
+
metadata: Record<string, unknown>;
|
|
29
|
+
tool?: { callID: string };
|
|
205
30
|
}
|
|
206
31
|
|
|
207
|
-
|
|
208
|
-
const current = readConfigFile(configPath);
|
|
209
|
-
if (current === null) {
|
|
210
|
-
throw new Error(`Config file ${configPath} is corrupted; refusing to overwrite`);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const next = deepMerge(deepMerge(DEFAULT_CONFIG, current), update);
|
|
214
|
-
|
|
215
|
-
mkdirSync(dirname(configPath), { recursive: true });
|
|
216
|
-
writeFileSync(configPath, JSON.stringify(next, null, 2) + '\n');
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function loadConfig(baseDirectory: string, optionOverrides: SandboxConfigOverrides): SandboxConfig {
|
|
220
|
-
const { globalPath, projectPath } = getConfigPaths(baseDirectory);
|
|
221
|
-
const globalConfig = readConfigFile(globalPath);
|
|
222
|
-
const projectConfig = readConfigFile(projectPath);
|
|
223
|
-
return deepMerge(
|
|
224
|
-
deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig ?? {}), projectConfig ?? {}),
|
|
225
|
-
optionOverrides,
|
|
226
|
-
);
|
|
227
|
-
}
|
|
32
|
+
type PermissionChoice = 'once' | 'session' | 'project' | 'global' | 'reject';
|
|
228
33
|
|
|
229
34
|
function list(values: string[]): string {
|
|
230
35
|
return values.join(', ') || '(none)';
|
|
@@ -263,85 +68,72 @@ function sandboxSummary(baseDirectory: string, optionOverrides: SandboxConfigOve
|
|
|
263
68
|
].join('\n');
|
|
264
69
|
}
|
|
265
70
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
function permissionType(permission: Record<string, unknown>, fallback = ''): string {
|
|
269
|
-
if (typeof permission.permission === 'string') return permission.permission;
|
|
270
|
-
if (typeof permission.action === 'string') return permission.action;
|
|
271
|
-
if (typeof permission.type === 'string') return permission.type;
|
|
272
|
-
return fallback;
|
|
71
|
+
function asRecord(permission: PendingPermission): Record<string, unknown> {
|
|
72
|
+
return permission as unknown as Record<string, unknown>;
|
|
273
73
|
}
|
|
274
74
|
|
|
275
|
-
function
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const pattern = permission.pattern;
|
|
281
|
-
if (typeof pattern === 'string') return pattern;
|
|
282
|
-
if (Array.isArray(pattern))
|
|
283
|
-
return pattern.find((item): item is string => typeof item === 'string');
|
|
284
|
-
|
|
285
|
-
return undefined;
|
|
75
|
+
function permissionDetail(permission: PendingPermission): string {
|
|
76
|
+
const label = permissionLabel(asRecord(permission));
|
|
77
|
+
const resource = permissionResource(asRecord(permission));
|
|
78
|
+
return resource && !label.includes(resource) ? `${label}: ${resource}` : label;
|
|
286
79
|
}
|
|
287
80
|
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
function
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
const command = typeof metadata.command === 'string' ? metadata.command : pattern;
|
|
305
|
-
const domains = typeof command === 'string' ? domainsFromCommand(command) : [];
|
|
306
|
-
return domains.length > 0 ? { network: { allowedDomains: domains } } : null;
|
|
81
|
+
const tui: TuiPlugin = async (api, options, meta) => {
|
|
82
|
+
const optionOverrides = normalizeOptions(options);
|
|
83
|
+
|
|
84
|
+
// Permission requests can arrive twice (the live event and a reconnect replay
|
|
85
|
+
// of `api.state`), so `resolved` tracks ids we have already answered and
|
|
86
|
+
// `activeId` guards against stacking a second sandbox dialog on the first.
|
|
87
|
+
const resolved = new Set<string>();
|
|
88
|
+
const queue: PendingPermission[] = [];
|
|
89
|
+
let activeId: string | undefined;
|
|
90
|
+
|
|
91
|
+
function pump(): void {
|
|
92
|
+
if (activeId !== undefined) return;
|
|
93
|
+
let next = queue.shift();
|
|
94
|
+
while (next && resolved.has(next.id)) next = queue.shift();
|
|
95
|
+
if (!next) return;
|
|
96
|
+
showPermission(next);
|
|
307
97
|
}
|
|
308
98
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
99
|
+
function enqueue(permission: PendingPermission): void {
|
|
100
|
+
if (!permission.id || resolved.has(permission.id)) return;
|
|
101
|
+
if (activeId === permission.id) return;
|
|
102
|
+
if (queue.some((item) => item.id === permission.id)) return;
|
|
103
|
+
queue.push(permission);
|
|
104
|
+
pump();
|
|
312
105
|
}
|
|
313
106
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
107
|
+
// Safety net for missed/late events and reconnects: fold whatever the host
|
|
108
|
+
// still considers pending for this session back into the queue.
|
|
109
|
+
function reconcile(sessionID: string): void {
|
|
110
|
+
for (const pending of api.state.session.permission(sessionID)) {
|
|
111
|
+
enqueue(pending as PendingPermission);
|
|
112
|
+
}
|
|
317
113
|
}
|
|
318
114
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
const tui: TuiPlugin = async (api, options) => {
|
|
330
|
-
const handledPermissions = new Set<string>();
|
|
115
|
+
function finishActive(id: string): void {
|
|
116
|
+
resolved.add(id);
|
|
117
|
+
if (activeId === id) {
|
|
118
|
+
activeId = undefined;
|
|
119
|
+
api.ui.dialog.clear();
|
|
120
|
+
}
|
|
121
|
+
pump();
|
|
122
|
+
}
|
|
331
123
|
|
|
332
124
|
async function replyPermission(
|
|
333
|
-
permission:
|
|
125
|
+
permission: PendingPermission,
|
|
334
126
|
choice: PermissionChoice,
|
|
335
127
|
): Promise<void> {
|
|
336
|
-
const
|
|
337
|
-
if (!id ||
|
|
128
|
+
const { id, sessionID } = permission;
|
|
129
|
+
if (!id || !sessionID) return;
|
|
338
130
|
|
|
339
131
|
const directory = api.state.path.directory || process.cwd();
|
|
340
132
|
const { globalPath, projectPath } = getConfigPaths(directory);
|
|
341
133
|
|
|
342
134
|
try {
|
|
343
135
|
if (choice === 'project' || choice === 'global') {
|
|
344
|
-
const update = updateForPermission(permission);
|
|
136
|
+
const update = updateForPermission(asRecord(permission));
|
|
345
137
|
if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
|
|
346
138
|
}
|
|
347
139
|
|
|
@@ -362,54 +154,84 @@ const tui: TuiPlugin = async (api, options) => {
|
|
|
362
154
|
variant: 'warning',
|
|
363
155
|
});
|
|
364
156
|
} finally {
|
|
365
|
-
|
|
157
|
+
finishActive(id);
|
|
366
158
|
}
|
|
367
159
|
}
|
|
368
160
|
|
|
369
|
-
function showPermission(permission:
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
161
|
+
function showPermission(permission: PendingPermission): void {
|
|
162
|
+
activeId = permission.id;
|
|
163
|
+
|
|
164
|
+
void api.attention.notify({
|
|
165
|
+
title: 'Sandbox permission',
|
|
166
|
+
message: permissionDetail(permission),
|
|
167
|
+
sound: { name: 'permission' },
|
|
168
|
+
notification: true,
|
|
169
|
+
});
|
|
373
170
|
|
|
374
171
|
api.ui.dialog.replace(
|
|
375
172
|
() =>
|
|
376
173
|
api.ui.DialogSelect<PermissionChoice>({
|
|
377
174
|
title: 'Sandbox Permission',
|
|
378
|
-
placeholder:
|
|
175
|
+
placeholder: permissionDetail(permission),
|
|
379
176
|
options: [
|
|
380
|
-
{
|
|
177
|
+
{
|
|
178
|
+
title: 'Allow once',
|
|
179
|
+
value: 'once',
|
|
180
|
+
category: 'This request',
|
|
181
|
+
description: 'Approve only this request',
|
|
182
|
+
},
|
|
381
183
|
{
|
|
382
184
|
title: 'Allow for session',
|
|
383
185
|
value: 'session',
|
|
186
|
+
category: 'This request',
|
|
384
187
|
description: 'Use OpenCode session approval for matching requests',
|
|
385
188
|
},
|
|
386
189
|
{
|
|
387
190
|
title: 'Allow for project',
|
|
388
191
|
value: 'project',
|
|
192
|
+
category: 'Persist to sandbox.json',
|
|
389
193
|
description: 'Persist to .opencode/sandbox.json and approve this session',
|
|
390
194
|
},
|
|
391
195
|
{
|
|
392
196
|
title: 'Allow globally',
|
|
393
197
|
value: 'global',
|
|
198
|
+
category: 'Persist to sandbox.json',
|
|
394
199
|
description: 'Persist to ~/.config/opencode/sandbox.json and approve this session',
|
|
395
200
|
},
|
|
396
|
-
{
|
|
201
|
+
{
|
|
202
|
+
title: 'Reject',
|
|
203
|
+
value: 'reject',
|
|
204
|
+
category: 'Deny',
|
|
205
|
+
description: 'Deny this request',
|
|
206
|
+
},
|
|
397
207
|
],
|
|
398
208
|
onSelect: (option) => {
|
|
399
209
|
void replyPermission(permission, option.value);
|
|
400
210
|
},
|
|
401
211
|
}),
|
|
402
|
-
() =>
|
|
212
|
+
() => {
|
|
213
|
+
// Dialog dismissed (esc) without a choice: drop our hold so the next
|
|
214
|
+
// pending permission can surface, but leave it unresolved upstream.
|
|
215
|
+
if (activeId === permission.id) activeId = undefined;
|
|
216
|
+
api.ui.dialog.clear();
|
|
217
|
+
pump();
|
|
218
|
+
},
|
|
403
219
|
);
|
|
404
220
|
}
|
|
405
221
|
|
|
406
|
-
api.event.on('permission.asked', (event) => {
|
|
407
|
-
|
|
222
|
+
const unsubscribeAsked = api.event.on('permission.asked', (event) => {
|
|
223
|
+
const pending = event.properties as PendingPermission;
|
|
224
|
+
enqueue(pending);
|
|
225
|
+
reconcile(pending.sessionID);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const unsubscribeReplied = api.event.on('permission.replied', (event) => {
|
|
229
|
+
finishActive(event.properties.requestID);
|
|
408
230
|
});
|
|
409
231
|
|
|
410
232
|
const showSandbox = () => {
|
|
411
233
|
const directory = api.state.path.directory || process.cwd();
|
|
412
|
-
const message = sandboxSummary(directory,
|
|
234
|
+
const message = sandboxSummary(directory, optionOverrides);
|
|
413
235
|
|
|
414
236
|
api.ui.dialog.replace(
|
|
415
237
|
() =>
|
|
@@ -430,64 +252,85 @@ const tui: TuiPlugin = async (api, options) => {
|
|
|
430
252
|
api.keymap.registerLayer({
|
|
431
253
|
commands: [
|
|
432
254
|
{
|
|
255
|
+
namespace: 'palette',
|
|
433
256
|
name: 'sandbox',
|
|
434
257
|
title: 'Sandbox',
|
|
435
|
-
|
|
258
|
+
desc: 'Show sandbox configuration',
|
|
436
259
|
category: 'Sandbox',
|
|
437
260
|
suggested: true,
|
|
438
|
-
|
|
261
|
+
slashName: 'sandbox',
|
|
439
262
|
run: showSandbox,
|
|
440
263
|
},
|
|
441
264
|
{
|
|
265
|
+
namespace: 'palette',
|
|
442
266
|
name: 'sandbox-disable',
|
|
443
267
|
title: 'Disable sandbox',
|
|
444
|
-
|
|
268
|
+
desc: 'Disable sandbox for this session',
|
|
445
269
|
category: 'Sandbox',
|
|
446
270
|
suggested: true,
|
|
447
|
-
|
|
271
|
+
slashName: 'sandbox-disable',
|
|
448
272
|
run: () => executeServerCommand('sandbox-disable'),
|
|
449
273
|
},
|
|
450
274
|
{
|
|
275
|
+
namespace: 'palette',
|
|
451
276
|
name: 'sandbox-enable',
|
|
452
277
|
title: 'Enable sandbox',
|
|
453
|
-
|
|
278
|
+
desc: 'Re-enable sandbox for this session',
|
|
454
279
|
category: 'Sandbox',
|
|
455
280
|
suggested: true,
|
|
456
|
-
|
|
281
|
+
slashName: 'sandbox-enable',
|
|
457
282
|
run: () => executeServerCommand('sandbox-enable'),
|
|
458
283
|
},
|
|
459
284
|
],
|
|
460
285
|
});
|
|
461
286
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
{
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
287
|
+
// Persistent status badge in the prompt area. It needs the host's Solid
|
|
288
|
+
// runtime, imported defensively so a host that resolves plugin imports
|
|
289
|
+
// differently still loads the plugin — the badge just stays absent there.
|
|
290
|
+
try {
|
|
291
|
+
const { jsx } = await import('@opentui/solid/jsx-runtime');
|
|
292
|
+
const statusBadge = (ctx: TuiSlotContext) => {
|
|
293
|
+
const directory = api.state.path.directory || process.cwd();
|
|
294
|
+
const config = loadConfig(directory, optionOverrides);
|
|
295
|
+
const theme = ctx.theme.current;
|
|
296
|
+
|
|
297
|
+
if (!config.enabled) return jsx('text', { fg: theme.textMuted, children: 'sandbox off' });
|
|
298
|
+
|
|
299
|
+
const open = config.network.allowNetwork;
|
|
300
|
+
return jsx('text', {
|
|
301
|
+
fg: open ? theme.warning : theme.success,
|
|
302
|
+
children: `sandbox · ${open ? 'net open' : 'net proxied'}`,
|
|
303
|
+
});
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const statusSlot: TuiSlotPlugin = {
|
|
307
|
+
slots: {
|
|
308
|
+
home_prompt_right: (ctx) => statusBadge(ctx),
|
|
309
|
+
session_prompt_right: (ctx) => statusBadge(ctx),
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
api.slots.register(statusSlot);
|
|
313
|
+
} catch {
|
|
314
|
+
// Solid runtime unavailable on this host — skip the status badge.
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// First-run onboarding: a single quiet pointer to the default-strict policy
|
|
318
|
+
// and the inspector command. `meta.state` flags a freshly installed plugin;
|
|
319
|
+
// the kv flag keeps it from repeating across reloads.
|
|
320
|
+
if (meta.state === 'first' && !api.kv.get<boolean>('onboarded', false)) {
|
|
321
|
+
api.kv.set('onboarded', true);
|
|
322
|
+
api.ui.toast({
|
|
323
|
+
title: 'Sandbox active',
|
|
324
|
+
message: 'Landlock policy is on (default strict). Run /sandbox to inspect it.',
|
|
325
|
+
variant: 'info',
|
|
326
|
+
duration: 8000,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
api.lifecycle.onDispose(() => {
|
|
331
|
+
unsubscribeAsked();
|
|
332
|
+
unsubscribeReplied();
|
|
333
|
+
});
|
|
491
334
|
};
|
|
492
335
|
|
|
493
336
|
export { tui };
|