opencode-landstrip 0.3.11 → 0.3.13
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 +28 -10
- package/index.ts +151 -218
- package/package.json +8 -7
- package/sandbox.json +7 -47
- package/shared.ts +318 -0
- package/tui.ts +265 -200
package/tui.ts
CHANGED
|
@@ -1,190 +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
|
-
'npmjs.org',
|
|
49
|
-
'*.npmjs.org',
|
|
50
|
-
'registry.npmjs.org',
|
|
51
|
-
'registry.yarnpkg.com',
|
|
52
|
-
'pypi.org',
|
|
53
|
-
'*.pypi.org',
|
|
54
|
-
'github.com',
|
|
55
|
-
'*.github.com',
|
|
56
|
-
'api.github.com',
|
|
57
|
-
'raw.githubusercontent.com',
|
|
58
|
-
'crates.io',
|
|
59
|
-
'*.crates.io',
|
|
60
|
-
'static.crates.io',
|
|
61
|
-
],
|
|
62
|
-
deniedDomains: [],
|
|
63
|
-
},
|
|
64
|
-
filesystem: {
|
|
65
|
-
denyRead: ['/Users', '/home'],
|
|
66
|
-
allowRead: [
|
|
67
|
-
'.',
|
|
68
|
-
'/dev/null',
|
|
69
|
-
'~/.config/opencode',
|
|
70
|
-
'~/.config/git',
|
|
71
|
-
'~/.gitconfig',
|
|
72
|
-
'~/.local',
|
|
73
|
-
'~/.cargo',
|
|
74
|
-
],
|
|
75
|
-
allowWrite: ['.', '/tmp', '/dev/null'],
|
|
76
|
-
denyWrite: ['.env', '.env.*', '*.pem', '*.key'],
|
|
77
|
-
},
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
81
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function stringArray(value: unknown): string[] | undefined {
|
|
85
|
-
if (!Array.isArray(value)) return undefined;
|
|
86
|
-
return value.every((item) => typeof item === 'string') ? [...value] : undefined;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function normalizeNetworkConfig(value: unknown): Partial<SandboxNetworkConfig> | undefined {
|
|
90
|
-
if (!isRecord(value)) return undefined;
|
|
91
|
-
|
|
92
|
-
const config: Partial<SandboxNetworkConfig> = {};
|
|
93
|
-
if (typeof value.allowNetwork === 'boolean') config.allowNetwork = value.allowNetwork;
|
|
94
|
-
if (typeof value.allowLocalBinding === 'boolean')
|
|
95
|
-
config.allowLocalBinding = value.allowLocalBinding;
|
|
96
|
-
if (typeof value.allowAllUnixSockets === 'boolean')
|
|
97
|
-
config.allowAllUnixSockets = value.allowAllUnixSockets;
|
|
98
|
-
|
|
99
|
-
const allowUnixSockets = stringArray(value.allowUnixSockets);
|
|
100
|
-
if (allowUnixSockets) config.allowUnixSockets = allowUnixSockets;
|
|
101
|
-
|
|
102
|
-
const allowedDomains = stringArray(value.allowedDomains);
|
|
103
|
-
if (allowedDomains) config.allowedDomains = allowedDomains;
|
|
104
|
-
|
|
105
|
-
const deniedDomains = stringArray(value.deniedDomains);
|
|
106
|
-
if (deniedDomains) config.deniedDomains = deniedDomains;
|
|
107
|
-
|
|
108
|
-
return config;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function normalizeFilesystemConfig(value: unknown): Partial<SandboxFilesystemConfig> | undefined {
|
|
112
|
-
if (!isRecord(value)) return undefined;
|
|
113
|
-
|
|
114
|
-
const config: Partial<SandboxFilesystemConfig> = {};
|
|
115
|
-
const denyRead = stringArray(value.denyRead);
|
|
116
|
-
if (denyRead) config.denyRead = denyRead;
|
|
117
|
-
|
|
118
|
-
const allowRead = stringArray(value.allowRead);
|
|
119
|
-
if (allowRead) config.allowRead = allowRead;
|
|
120
|
-
|
|
121
|
-
const allowWrite = stringArray(value.allowWrite);
|
|
122
|
-
if (allowWrite) config.allowWrite = allowWrite;
|
|
123
|
-
|
|
124
|
-
const denyWrite = stringArray(value.denyWrite);
|
|
125
|
-
if (denyWrite) config.denyWrite = denyWrite;
|
|
126
|
-
|
|
127
|
-
return config;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function normalizeConfig(value: unknown): SandboxConfigOverrides {
|
|
131
|
-
if (!isRecord(value)) return {};
|
|
132
|
-
|
|
133
|
-
const config: SandboxConfigOverrides = {};
|
|
134
|
-
if (typeof value.enabled === 'boolean') config.enabled = value.enabled;
|
|
135
|
-
|
|
136
|
-
const network = normalizeNetworkConfig(value.network);
|
|
137
|
-
if (network) config.network = network;
|
|
138
|
-
|
|
139
|
-
const filesystem = normalizeFilesystemConfig(value.filesystem);
|
|
140
|
-
if (filesystem) config.filesystem = filesystem;
|
|
141
|
-
|
|
142
|
-
return config;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function normalizeOptions(options: unknown): SandboxConfigOverrides {
|
|
146
|
-
if (!isRecord(options)) return {};
|
|
147
|
-
return normalizeConfig(isRecord(options.config) ? options.config : options);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): SandboxConfig {
|
|
151
|
-
return {
|
|
152
|
-
enabled: overrides.enabled ?? base.enabled,
|
|
153
|
-
network: {
|
|
154
|
-
...base.network,
|
|
155
|
-
...overrides.network,
|
|
156
|
-
},
|
|
157
|
-
filesystem: {
|
|
158
|
-
...base.filesystem,
|
|
159
|
-
...overrides.filesystem,
|
|
160
|
-
},
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function getConfigPaths(baseDirectory: string): { globalPath: string; projectPath: string } {
|
|
165
|
-
return {
|
|
166
|
-
globalPath: join(homedir(), '.config', 'opencode', 'sandbox.json'),
|
|
167
|
-
projectPath: join(baseDirectory, '.opencode', 'sandbox.json'),
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function readConfigFile(configPath: string): SandboxConfigOverrides {
|
|
172
|
-
if (!existsSync(configPath)) return {};
|
|
173
|
-
|
|
174
|
-
try {
|
|
175
|
-
return normalizeConfig(JSON.parse(readFileSync(configPath, 'utf-8')));
|
|
176
|
-
} catch {
|
|
177
|
-
return {};
|
|
178
|
-
}
|
|
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 };
|
|
179
30
|
}
|
|
180
31
|
|
|
181
|
-
|
|
182
|
-
const { globalPath, projectPath } = getConfigPaths(baseDirectory);
|
|
183
|
-
return deepMerge(
|
|
184
|
-
deepMerge(deepMerge(DEFAULT_CONFIG, readConfigFile(globalPath)), readConfigFile(projectPath)),
|
|
185
|
-
optionOverrides,
|
|
186
|
-
);
|
|
187
|
-
}
|
|
32
|
+
type PermissionChoice = 'once' | 'session' | 'project' | 'global' | 'reject';
|
|
188
33
|
|
|
189
34
|
function list(values: string[]): string {
|
|
190
35
|
return values.join(', ') || '(none)';
|
|
@@ -201,7 +46,7 @@ function sandboxSummary(baseDirectory: string, optionOverrides: SandboxConfigOve
|
|
|
201
46
|
|
|
202
47
|
return [
|
|
203
48
|
`Status: ${config.enabled ? 'active' : 'disabled by config'}`,
|
|
204
|
-
`landstrip: ${
|
|
49
|
+
`landstrip package binary: ${landstripBinaryPath()}`,
|
|
205
50
|
'',
|
|
206
51
|
'Config files',
|
|
207
52
|
configPathLine('project', projectPath),
|
|
@@ -223,10 +68,170 @@ function sandboxSummary(baseDirectory: string, optionOverrides: SandboxConfigOve
|
|
|
223
68
|
].join('\n');
|
|
224
69
|
}
|
|
225
70
|
|
|
226
|
-
|
|
71
|
+
function asRecord(permission: PendingPermission): Record<string, unknown> {
|
|
72
|
+
return permission as unknown as Record<string, unknown>;
|
|
73
|
+
}
|
|
74
|
+
|
|
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;
|
|
79
|
+
}
|
|
80
|
+
|
|
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);
|
|
97
|
+
}
|
|
98
|
+
|
|
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();
|
|
105
|
+
}
|
|
106
|
+
|
|
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
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
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
|
+
}
|
|
123
|
+
|
|
124
|
+
async function replyPermission(
|
|
125
|
+
permission: PendingPermission,
|
|
126
|
+
choice: PermissionChoice,
|
|
127
|
+
): Promise<void> {
|
|
128
|
+
const { id, sessionID } = permission;
|
|
129
|
+
if (!id || !sessionID) return;
|
|
130
|
+
|
|
131
|
+
const directory = api.state.path.directory || process.cwd();
|
|
132
|
+
const { globalPath, projectPath } = getConfigPaths(directory);
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
if (choice === 'project' || choice === 'global') {
|
|
136
|
+
const update = updateForPermission(asRecord(permission));
|
|
137
|
+
if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await api.client.permission.reply({
|
|
141
|
+
requestID: id,
|
|
142
|
+
reply: choice === 'reject' ? 'reject' : choice === 'once' ? 'once' : 'always',
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
api.ui.toast({
|
|
146
|
+
title: 'Sandbox',
|
|
147
|
+
message: choice === 'reject' ? 'Permission rejected' : `Permission allowed for ${choice}`,
|
|
148
|
+
variant: choice === 'reject' ? 'warning' : 'success',
|
|
149
|
+
});
|
|
150
|
+
} catch {
|
|
151
|
+
api.ui.toast({
|
|
152
|
+
title: 'Sandbox',
|
|
153
|
+
message: 'Permission was already handled or could not be updated',
|
|
154
|
+
variant: 'warning',
|
|
155
|
+
});
|
|
156
|
+
} finally {
|
|
157
|
+
finishActive(id);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
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
|
+
});
|
|
170
|
+
|
|
171
|
+
api.ui.dialog.replace(
|
|
172
|
+
() =>
|
|
173
|
+
api.ui.DialogSelect<PermissionChoice>({
|
|
174
|
+
title: 'Sandbox Permission',
|
|
175
|
+
placeholder: permissionDetail(permission),
|
|
176
|
+
options: [
|
|
177
|
+
{
|
|
178
|
+
title: 'Allow once',
|
|
179
|
+
value: 'once',
|
|
180
|
+
category: 'This request',
|
|
181
|
+
description: 'Approve only this request',
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
title: 'Allow for session',
|
|
185
|
+
value: 'session',
|
|
186
|
+
category: 'This request',
|
|
187
|
+
description: 'Use OpenCode session approval for matching requests',
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
title: 'Allow for project',
|
|
191
|
+
value: 'project',
|
|
192
|
+
category: 'Persist to sandbox.json',
|
|
193
|
+
description: 'Persist to .opencode/sandbox.json and approve this session',
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
title: 'Allow globally',
|
|
197
|
+
value: 'global',
|
|
198
|
+
category: 'Persist to sandbox.json',
|
|
199
|
+
description: 'Persist to ~/.config/opencode/sandbox.json and approve this session',
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
title: 'Reject',
|
|
203
|
+
value: 'reject',
|
|
204
|
+
category: 'Deny',
|
|
205
|
+
description: 'Deny this request',
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
onSelect: (option) => {
|
|
209
|
+
void replyPermission(permission, option.value);
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
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
|
+
},
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
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);
|
|
230
|
+
});
|
|
231
|
+
|
|
227
232
|
const showSandbox = () => {
|
|
228
233
|
const directory = api.state.path.directory || process.cwd();
|
|
229
|
-
const message = sandboxSummary(directory,
|
|
234
|
+
const message = sandboxSummary(directory, optionOverrides);
|
|
230
235
|
|
|
231
236
|
api.ui.dialog.replace(
|
|
232
237
|
() =>
|
|
@@ -239,33 +244,93 @@ const tui: TuiPlugin = async (api, options) => {
|
|
|
239
244
|
);
|
|
240
245
|
};
|
|
241
246
|
|
|
247
|
+
const executeServerCommand = async (command: string): Promise<boolean> => {
|
|
248
|
+
await api.client.tui.executeCommand({ command });
|
|
249
|
+
return true;
|
|
250
|
+
};
|
|
251
|
+
|
|
242
252
|
api.keymap.registerLayer({
|
|
243
253
|
commands: [
|
|
244
254
|
{
|
|
245
255
|
namespace: 'palette',
|
|
246
|
-
name: '
|
|
247
|
-
title: '
|
|
248
|
-
desc: 'Show
|
|
249
|
-
description: 'Show landstrip sandbox status and rules',
|
|
256
|
+
name: 'sandbox',
|
|
257
|
+
title: 'Sandbox',
|
|
258
|
+
desc: 'Show sandbox configuration',
|
|
250
259
|
category: 'Sandbox',
|
|
251
260
|
suggested: true,
|
|
252
261
|
slashName: 'sandbox',
|
|
253
262
|
run: showSandbox,
|
|
254
263
|
},
|
|
264
|
+
{
|
|
265
|
+
namespace: 'palette',
|
|
266
|
+
name: 'sandbox-disable',
|
|
267
|
+
title: 'Disable sandbox',
|
|
268
|
+
desc: 'Disable sandbox for this session',
|
|
269
|
+
category: 'Sandbox',
|
|
270
|
+
suggested: true,
|
|
271
|
+
slashName: 'sandbox-disable',
|
|
272
|
+
run: () => executeServerCommand('sandbox-disable'),
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
namespace: 'palette',
|
|
276
|
+
name: 'sandbox-enable',
|
|
277
|
+
title: 'Enable sandbox',
|
|
278
|
+
desc: 'Re-enable sandbox for this session',
|
|
279
|
+
category: 'Sandbox',
|
|
280
|
+
suggested: true,
|
|
281
|
+
slashName: 'sandbox-enable',
|
|
282
|
+
run: () => executeServerCommand('sandbox-enable'),
|
|
283
|
+
},
|
|
255
284
|
],
|
|
256
285
|
});
|
|
257
286
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
+
});
|
|
269
334
|
};
|
|
270
335
|
|
|
271
336
|
export { tui };
|