opencode-landstrip 0.19.3 → 0.19.4
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 +1 -1
- package/index.ts +296 -387
- package/package.json +9 -8
- package/tui.ts +742 -730
package/tui.ts
CHANGED
|
@@ -5,16 +5,12 @@ import { type AddressInfo, createServer, type Socket as NetSocket } from 'node:n
|
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
|
|
8
|
-
import type {
|
|
9
|
-
|
|
10
|
-
TuiPlugin,
|
|
11
|
-
TuiSlotContext,
|
|
12
|
-
TuiSlotPlugin,
|
|
13
|
-
} from '@opencode-ai/plugin/tui';
|
|
8
|
+
import type { Plugin as TuiPlugin } from '@opencode/plugin/tui';
|
|
9
|
+
import type { ResolvedTheme } from '@opencode/theme/tui';
|
|
14
10
|
import { RGBA } from '@opentui/core';
|
|
15
11
|
import { Portal, useTerminalDimensions } from '@opentui/solid';
|
|
16
12
|
import { Fragment, jsx, jsxs } from '@opentui/solid/jsx-runtime';
|
|
17
|
-
import { createSignal,
|
|
13
|
+
import { createSignal, onMount } from 'solid-js';
|
|
18
14
|
|
|
19
15
|
import {
|
|
20
16
|
controlResponseLine,
|
|
@@ -118,742 +114,743 @@ function formatPath(input: string, base: string): string {
|
|
|
118
114
|
return absolute;
|
|
119
115
|
}
|
|
120
116
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
117
|
+
function resolveThemeColors(theme: ResolvedTheme) {
|
|
118
|
+
return {
|
|
119
|
+
primary: theme.text.action.primary.base,
|
|
120
|
+
text: theme.text.base,
|
|
121
|
+
textMuted: theme.text.muted,
|
|
122
|
+
background: theme.background.base,
|
|
123
|
+
backgroundPanel: theme.background.raised.base,
|
|
124
|
+
backgroundMenu: theme.background.raised.high,
|
|
125
|
+
backgroundElement: theme.background.action.secondary.base,
|
|
126
|
+
border: theme.border.base,
|
|
127
|
+
warning: theme.text.feedback.warning.base,
|
|
128
|
+
warningRgba: theme.text.feedback.warning.base,
|
|
129
|
+
success: theme.text.feedback.success.base,
|
|
130
|
+
error: theme.text.feedback.error.base,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
132
133
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
134
|
+
const tui: TuiPlugin.Definition = {
|
|
135
|
+
id: 'opencode-landstrip',
|
|
136
|
+
async setup(context: TuiPlugin.Context) {
|
|
137
|
+
const directory = context.location?.directory ?? process.cwd();
|
|
138
|
+
const optionOverrides = normalizeOptions(context.options);
|
|
139
|
+
const enabledManagedByOptions = optionOverrides.enabled !== undefined;
|
|
140
|
+
|
|
141
|
+
function LandstripPermissionPrompt(rawProps: Record<string, unknown>) {
|
|
142
|
+
const props = rawProps as unknown as PermissionPromptProps<string>;
|
|
143
|
+
const theme = resolveThemeColors(context.theme);
|
|
144
|
+
const dimensions = useTerminalDimensions();
|
|
145
|
+
const [selected, setSelected] = createSignal(0);
|
|
146
|
+
const [expanded, setExpanded] = createSignal(false);
|
|
147
|
+
onMount(props.onShow);
|
|
148
|
+
|
|
149
|
+
function move(direction: number): void {
|
|
150
|
+
setSelected((index) => (index + direction + props.options.length) % props.options.length);
|
|
151
|
+
}
|
|
136
152
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
153
|
+
function submit(): void {
|
|
154
|
+
const option = props.options[selected()];
|
|
155
|
+
if (option) props.onSelect(option.value);
|
|
156
|
+
}
|
|
141
157
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
cmd: () => setExpanded((value) => !value),
|
|
154
|
-
},
|
|
155
|
-
],
|
|
156
|
-
});
|
|
157
|
-
onCleanup(unregister);
|
|
158
|
-
|
|
159
|
-
const optionButtons = props.options.map((option, index) =>
|
|
160
|
-
jsx('box', {
|
|
161
|
-
paddingLeft: 1,
|
|
162
|
-
paddingRight: 1,
|
|
163
|
-
flexShrink: 0,
|
|
164
|
-
get backgroundColor() {
|
|
165
|
-
return selected() === index ? theme.warning : theme.backgroundMenu;
|
|
166
|
-
},
|
|
167
|
-
onMouseOver: () => setSelected(index),
|
|
168
|
-
onMouseUp: () => {
|
|
169
|
-
setSelected(index);
|
|
170
|
-
props.onSelect(option.value);
|
|
171
|
-
},
|
|
172
|
-
children: jsx('text', {
|
|
173
|
-
get fg() {
|
|
174
|
-
if (selected() !== index) return theme.textMuted;
|
|
175
|
-
const background = theme.warning;
|
|
176
|
-
const luminance = 0.2126 * background.r + 0.7152 * background.g + 0.0722 * background.b;
|
|
177
|
-
return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255);
|
|
158
|
+
context.keymap.layer(() => ({
|
|
159
|
+
priority: 1000,
|
|
160
|
+
commands: [
|
|
161
|
+
{ id: 'landstrip-perm-prev', bind: 'left', run: () => move(-1) },
|
|
162
|
+
{ id: 'landstrip-perm-next', bind: 'right', run: () => move(1) },
|
|
163
|
+
{ id: 'landstrip-perm-submit', bind: 'return', run: submit },
|
|
164
|
+
{ id: 'landstrip-perm-cancel', bind: 'escape', run: props.onCancel },
|
|
165
|
+
{
|
|
166
|
+
id: 'landstrip-perm-fullscreen',
|
|
167
|
+
bind: 'ctrl+f',
|
|
168
|
+
run: () => setExpanded((value) => !value),
|
|
178
169
|
},
|
|
179
|
-
children: option.label,
|
|
180
|
-
}),
|
|
181
|
-
}),
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
const children = [
|
|
185
|
-
jsxs('box', {
|
|
186
|
-
gap: 1,
|
|
187
|
-
paddingLeft: 1,
|
|
188
|
-
paddingRight: 3,
|
|
189
|
-
paddingTop: 1,
|
|
190
|
-
paddingBottom: 1,
|
|
191
|
-
flexGrow: 1,
|
|
192
|
-
children: [
|
|
193
|
-
jsxs('box', {
|
|
194
|
-
flexDirection: 'column',
|
|
195
|
-
gap: 0,
|
|
196
|
-
paddingLeft: 1,
|
|
197
|
-
flexShrink: 0,
|
|
198
|
-
children: [
|
|
199
|
-
jsxs('box', {
|
|
200
|
-
flexDirection: 'row',
|
|
201
|
-
gap: 1,
|
|
202
|
-
flexShrink: 0,
|
|
203
|
-
children: [
|
|
204
|
-
jsx('text', { fg: theme.warning, children: '△' }),
|
|
205
|
-
jsx('text', { fg: theme.text, children: 'Permission required' }),
|
|
206
|
-
],
|
|
207
|
-
}),
|
|
208
|
-
jsxs('box', {
|
|
209
|
-
flexDirection: 'row',
|
|
210
|
-
gap: 1,
|
|
211
|
-
paddingLeft: 2,
|
|
212
|
-
flexShrink: 0,
|
|
213
|
-
children: [
|
|
214
|
-
jsx('text', { fg: theme.textMuted, flexShrink: 0, children: props.icon }),
|
|
215
|
-
jsx('text', { fg: theme.text, wrapMode: 'word', children: props.title }),
|
|
216
|
-
],
|
|
217
|
-
}),
|
|
218
|
-
],
|
|
219
|
-
}),
|
|
220
170
|
],
|
|
221
|
-
})
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
children:
|
|
245
|
-
}),
|
|
246
|
-
jsxs('box', {
|
|
247
|
-
flexDirection: 'row',
|
|
248
|
-
gap: 2,
|
|
249
|
-
flexShrink: 0,
|
|
250
|
-
children: [
|
|
251
|
-
jsxs('text', {
|
|
252
|
-
fg: theme.text,
|
|
253
|
-
children: [
|
|
254
|
-
'ctrl+f ',
|
|
255
|
-
jsx('span', {
|
|
256
|
-
style: { fg: theme.textMuted },
|
|
257
|
-
children: expanded() ? 'minimize' : 'fullscreen',
|
|
258
|
-
}),
|
|
259
|
-
],
|
|
260
|
-
}),
|
|
261
|
-
jsxs('text', {
|
|
262
|
-
fg: theme.text,
|
|
263
|
-
children: [
|
|
264
|
-
'⇆ ',
|
|
265
|
-
jsx('span', { style: { fg: theme.textMuted }, children: 'select' }),
|
|
266
|
-
],
|
|
267
|
-
}),
|
|
268
|
-
jsxs('text', {
|
|
269
|
-
fg: theme.text,
|
|
270
|
-
children: [
|
|
271
|
-
'enter ',
|
|
272
|
-
jsx('span', { style: { fg: theme.textMuted }, children: 'confirm' }),
|
|
273
|
-
],
|
|
274
|
-
}),
|
|
275
|
-
],
|
|
171
|
+
}));
|
|
172
|
+
|
|
173
|
+
const optionButtons = props.options.map((option, index) =>
|
|
174
|
+
jsx('box', {
|
|
175
|
+
paddingLeft: 1,
|
|
176
|
+
paddingRight: 1,
|
|
177
|
+
flexShrink: 0,
|
|
178
|
+
get backgroundColor() {
|
|
179
|
+
return selected() === index ? theme.warning : theme.backgroundMenu;
|
|
180
|
+
},
|
|
181
|
+
onMouseOver: () => setSelected(index),
|
|
182
|
+
onMouseUp: () => {
|
|
183
|
+
setSelected(index);
|
|
184
|
+
props.onSelect(option.value);
|
|
185
|
+
},
|
|
186
|
+
children: jsx('text', {
|
|
187
|
+
get fg() {
|
|
188
|
+
if (selected() !== index) return theme.textMuted;
|
|
189
|
+
const background = theme.warningRgba;
|
|
190
|
+
const luminance =
|
|
191
|
+
0.2126 * background.r + 0.7152 * background.g + 0.0722 * background.b;
|
|
192
|
+
return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255);
|
|
193
|
+
},
|
|
194
|
+
children: option.label,
|
|
276
195
|
}),
|
|
277
|
-
],
|
|
278
|
-
}),
|
|
279
|
-
];
|
|
280
|
-
|
|
281
|
-
if (expanded()) {
|
|
282
|
-
return Portal({
|
|
283
|
-
children: jsxs('box', {
|
|
284
|
-
top: dimensions().height * -1 + 1,
|
|
285
|
-
bottom: 1,
|
|
286
|
-
left: 2,
|
|
287
|
-
right: 2,
|
|
288
|
-
position: 'absolute',
|
|
289
|
-
border: ['left'],
|
|
290
|
-
borderColor: theme.warning,
|
|
291
|
-
customBorderChars: promptBorderChars,
|
|
292
|
-
backgroundColor: theme.backgroundPanel,
|
|
293
|
-
children,
|
|
294
196
|
}),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const children = [
|
|
200
|
+
jsxs('box', {
|
|
201
|
+
gap: 1,
|
|
202
|
+
paddingLeft: 1,
|
|
203
|
+
paddingRight: 3,
|
|
204
|
+
paddingTop: 1,
|
|
205
|
+
paddingBottom: 1,
|
|
206
|
+
flexGrow: 1,
|
|
207
|
+
children: [
|
|
208
|
+
jsxs('box', {
|
|
209
|
+
flexDirection: 'column',
|
|
210
|
+
gap: 0,
|
|
211
|
+
paddingLeft: 1,
|
|
212
|
+
flexShrink: 0,
|
|
213
|
+
children: [
|
|
214
|
+
jsxs('box', {
|
|
215
|
+
flexDirection: 'row',
|
|
216
|
+
gap: 1,
|
|
217
|
+
flexShrink: 0,
|
|
218
|
+
children: [
|
|
219
|
+
jsx('text', { fg: theme.warning, children: '△' }),
|
|
220
|
+
jsx('text', { fg: theme.text, children: 'Permission required' }),
|
|
221
|
+
],
|
|
222
|
+
}),
|
|
223
|
+
jsxs('box', {
|
|
224
|
+
flexDirection: 'row',
|
|
225
|
+
gap: 1,
|
|
226
|
+
paddingLeft: 2,
|
|
227
|
+
flexShrink: 0,
|
|
228
|
+
children: [
|
|
229
|
+
jsx('text', { fg: theme.textMuted, flexShrink: 0, children: props.icon }),
|
|
230
|
+
jsx('text', { fg: theme.text, wrapMode: 'word', children: props.title }),
|
|
231
|
+
],
|
|
232
|
+
}),
|
|
233
|
+
],
|
|
234
|
+
}),
|
|
235
|
+
],
|
|
236
|
+
}),
|
|
237
|
+
jsxs('box', {
|
|
238
|
+
get flexDirection() {
|
|
239
|
+
return dimensions().width < 80 ? 'column' : 'row';
|
|
240
|
+
},
|
|
241
|
+
flexShrink: 0,
|
|
242
|
+
gap: 1,
|
|
243
|
+
paddingTop: 1,
|
|
244
|
+
paddingLeft: 2,
|
|
245
|
+
paddingRight: 3,
|
|
246
|
+
paddingBottom: 1,
|
|
247
|
+
backgroundColor: theme.backgroundElement,
|
|
248
|
+
get justifyContent() {
|
|
249
|
+
return dimensions().width < 80 ? 'flex-start' : 'space-between';
|
|
250
|
+
},
|
|
251
|
+
get alignItems() {
|
|
252
|
+
return dimensions().width < 80 ? 'flex-start' : 'center';
|
|
253
|
+
},
|
|
254
|
+
children: [
|
|
255
|
+
jsx('box', {
|
|
256
|
+
flexDirection: 'row',
|
|
257
|
+
gap: 1,
|
|
258
|
+
flexShrink: 0,
|
|
259
|
+
children: optionButtons,
|
|
260
|
+
}),
|
|
261
|
+
jsxs('box', {
|
|
262
|
+
flexDirection: 'row',
|
|
263
|
+
gap: 2,
|
|
264
|
+
flexShrink: 0,
|
|
265
|
+
children: [
|
|
266
|
+
jsxs('text', {
|
|
267
|
+
fg: theme.text,
|
|
268
|
+
children: [
|
|
269
|
+
'ctrl+f ',
|
|
270
|
+
jsx('span', {
|
|
271
|
+
style: { fg: theme.textMuted },
|
|
272
|
+
children: expanded() ? 'minimize' : 'fullscreen',
|
|
273
|
+
}),
|
|
274
|
+
],
|
|
275
|
+
}),
|
|
276
|
+
jsxs('text', {
|
|
277
|
+
fg: theme.text,
|
|
278
|
+
children: [
|
|
279
|
+
'⇆ ',
|
|
280
|
+
jsx('span', { style: { fg: theme.textMuted }, children: 'select' }),
|
|
281
|
+
],
|
|
282
|
+
}),
|
|
283
|
+
jsxs('text', {
|
|
284
|
+
fg: theme.text,
|
|
285
|
+
children: [
|
|
286
|
+
'enter ',
|
|
287
|
+
jsx('span', { style: { fg: theme.textMuted }, children: 'confirm' }),
|
|
288
|
+
],
|
|
289
|
+
}),
|
|
290
|
+
],
|
|
291
|
+
}),
|
|
292
|
+
],
|
|
293
|
+
}),
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
if (expanded()) {
|
|
297
|
+
return Portal({
|
|
298
|
+
children: jsxs('box', {
|
|
299
|
+
top: dimensions().height * -1 + 1,
|
|
300
|
+
bottom: 1,
|
|
301
|
+
left: 2,
|
|
302
|
+
right: 2,
|
|
303
|
+
position: 'absolute',
|
|
304
|
+
border: ['left'],
|
|
305
|
+
borderColor: theme.warning,
|
|
306
|
+
customBorderChars: promptBorderChars,
|
|
307
|
+
backgroundColor: theme.backgroundPanel,
|
|
308
|
+
children,
|
|
309
|
+
}),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
return jsxs('box', {
|
|
313
|
+
top: 0,
|
|
314
|
+
maxHeight: 15,
|
|
315
|
+
bottom: 0,
|
|
316
|
+
left: 0,
|
|
317
|
+
right: 0,
|
|
318
|
+
position: 'relative',
|
|
319
|
+
border: ['left'],
|
|
320
|
+
borderColor: theme.warning,
|
|
321
|
+
customBorderChars: promptBorderChars,
|
|
322
|
+
backgroundColor: theme.backgroundPanel,
|
|
323
|
+
children,
|
|
295
324
|
});
|
|
296
325
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const sessionAllowances = new Map<string, SessionAllowances>();
|
|
320
|
-
const notified = new Set<string>();
|
|
321
|
-
|
|
322
|
-
// Filesystem and network queries still awaiting a response, so cleanup can
|
|
323
|
-
// release held syscalls instead of letting the child hang.
|
|
324
|
-
const liveQueries = new Set<FsQueryEntry | NetworkQueryEntry>();
|
|
325
|
-
|
|
326
|
-
function nativePermissionPending(entry: QueueEntry): boolean {
|
|
327
|
-
if (api.state.session.permission(entry.sessionID).length > 0) return true;
|
|
328
|
-
return (
|
|
329
|
-
entry.sourceSessionID !== entry.sessionID &&
|
|
330
|
-
api.state.session.permission(entry.sourceSessionID).length > 0
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function pump(): void {
|
|
335
|
-
const route = api.route.current;
|
|
336
|
-
if (route.name !== 'session' || !route.params) return;
|
|
337
|
-
const routeSessionID =
|
|
338
|
-
typeof route.params.sessionID === 'string' ? route.params.sessionID : undefined;
|
|
339
|
-
if (!routeSessionID) return;
|
|
340
|
-
|
|
341
|
-
const active = activeEntry();
|
|
342
|
-
if (activeId !== undefined) {
|
|
343
|
-
if (active?.sessionID === routeSessionID) return;
|
|
344
|
-
if (active && !resolved.has(active.id)) queue.unshift(active);
|
|
345
|
-
activeId = undefined;
|
|
346
|
-
setActiveEntry(undefined);
|
|
326
|
+
|
|
327
|
+
const resolved = new Set<string>();
|
|
328
|
+
const queue: QueueEntry[] = [];
|
|
329
|
+
const [activeEntry, setActiveEntry] = createSignal<QueueEntry>();
|
|
330
|
+
const [permissionRevision, setPermissionRevision] = createSignal(0);
|
|
331
|
+
let activeId: string | undefined;
|
|
332
|
+
let refreshSandboxStatus: (() => void) | undefined;
|
|
333
|
+
|
|
334
|
+
const sessionAllowances = new Map<string, SessionAllowances>();
|
|
335
|
+
const notified = new Set<string>();
|
|
336
|
+
|
|
337
|
+
// Filesystem and network queries still awaiting a response, so cleanup can
|
|
338
|
+
// release held syscalls instead of letting the child hang.
|
|
339
|
+
const liveQueries = new Set<FsQueryEntry | NetworkQueryEntry>();
|
|
340
|
+
|
|
341
|
+
function nativePermissionPending(entry: QueueEntry): boolean {
|
|
342
|
+
if ((context.data.session.permission.list(entry.sessionID)?.length ?? 0) > 0) return true;
|
|
343
|
+
return (
|
|
344
|
+
entry.sourceSessionID !== entry.sessionID &&
|
|
345
|
+
(context.data.session.permission.list(entry.sourceSessionID)?.length ?? 0) > 0
|
|
346
|
+
);
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
-
|
|
350
|
-
const
|
|
351
|
-
if (
|
|
349
|
+
function pump(): void {
|
|
350
|
+
const route = context.ui.router.current();
|
|
351
|
+
if (route.type !== 'session' || !route.sessionID) return;
|
|
352
|
+
const routeSessionID = route.sessionID;
|
|
353
|
+
|
|
354
|
+
const active = activeEntry();
|
|
355
|
+
if (activeId !== undefined) {
|
|
356
|
+
if (active?.sessionID === routeSessionID) return;
|
|
357
|
+
if (active && !resolved.has(active.id)) queue.unshift(active);
|
|
358
|
+
activeId = undefined;
|
|
359
|
+
setActiveEntry(undefined);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
for (let index = queue.length - 1; index >= 0; index -= 1) {
|
|
363
|
+
const entry = queue[index];
|
|
364
|
+
if (entry && resolved.has(entry.id)) queue.splice(index, 1);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const index = nextSandboxPermissionIndex(queue, routeSessionID, nativePermissionPending);
|
|
368
|
+
if (index === -1) return;
|
|
369
|
+
const [next] = queue.splice(index, 1);
|
|
370
|
+
if (!next) return;
|
|
371
|
+
activeId = next.id;
|
|
372
|
+
setActiveEntry(next);
|
|
352
373
|
}
|
|
353
374
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function enqueueEntry(entry: QueueEntry): void {
|
|
363
|
-
if (!entry.id || resolved.has(entry.id)) return;
|
|
364
|
-
if (activeId === entry.id) return;
|
|
365
|
-
if (queue.some((item) => item.id === entry.id)) return;
|
|
366
|
-
queue.push(entry);
|
|
367
|
-
pump();
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
function finishActive(id: string): void {
|
|
371
|
-
resolved.add(id);
|
|
372
|
-
notified.delete(id);
|
|
373
|
-
if (activeId === id) {
|
|
374
|
-
activeId = undefined;
|
|
375
|
-
setActiveEntry(undefined);
|
|
375
|
+
function enqueueEntry(entry: QueueEntry): void {
|
|
376
|
+
if (!entry.id || resolved.has(entry.id)) return;
|
|
377
|
+
if (activeId === entry.id) return;
|
|
378
|
+
if (queue.some((item) => item.id === entry.id)) return;
|
|
379
|
+
queue.push(entry);
|
|
380
|
+
pump();
|
|
376
381
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
.notify({
|
|
385
|
-
title: `Sandbox ${entry.operation} blocked`,
|
|
386
|
-
message: entry.kind === 'fs-query' ? entry.path : entry.target,
|
|
387
|
-
sound: { name: 'permission' },
|
|
388
|
-
notification: true,
|
|
389
|
-
})
|
|
390
|
-
.catch(() => undefined);
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
function renderSessionPrompt(props: TuiHostSlotMap['session_prompt']) {
|
|
394
|
-
return jsx(Fragment, {
|
|
395
|
-
get children() {
|
|
396
|
-
permissionRevision();
|
|
397
|
-
queueMicrotask(pump);
|
|
398
|
-
const entry = activeEntry();
|
|
399
|
-
const hostPromptActive =
|
|
400
|
-
props.visible === false ||
|
|
401
|
-
Boolean(props.disabled) ||
|
|
402
|
-
Boolean(entry && nativePermissionPending(entry));
|
|
403
|
-
const showEntry = Boolean(
|
|
404
|
-
entry &&
|
|
405
|
-
shouldRenderSandboxPermission(entry.sessionID, props.session_id, hostPromptActive),
|
|
406
|
-
);
|
|
407
|
-
|
|
408
|
-
if (showEntry && entry?.kind === 'fs-query') {
|
|
409
|
-
const verb = entry.operation === 'read' ? 'Read' : 'Write';
|
|
410
|
-
return jsx(LandstripPermissionPrompt, {
|
|
411
|
-
icon: '→',
|
|
412
|
-
title: `${verb} ${formatPath(entry.path, entry.directory)}`,
|
|
413
|
-
options: [
|
|
414
|
-
{ label: 'Allow once', value: 'once' },
|
|
415
|
-
{ label: 'Allow for session', value: 'session' },
|
|
416
|
-
{ label: 'Allow for project', value: 'project' },
|
|
417
|
-
{ label: 'Allow globally', value: 'global' },
|
|
418
|
-
{ label: 'Deny', value: 'deny' },
|
|
419
|
-
],
|
|
420
|
-
onSelect: (choice: QueryChoice) => resolveFsQuery(entry, choice),
|
|
421
|
-
onCancel: () => resolveFsQuery(entry, 'deny'),
|
|
422
|
-
onShow: () => notifyQuery(entry),
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
if (showEntry && entry?.kind === 'net-query') {
|
|
426
|
-
const verb = entry.operation
|
|
427
|
-
? entry.operation[0]?.toUpperCase() + entry.operation.slice(1)
|
|
428
|
-
: 'Network';
|
|
429
|
-
return jsx(LandstripPermissionPrompt, {
|
|
430
|
-
icon: '%',
|
|
431
|
-
title: `${verb} ${entry.target}`,
|
|
432
|
-
options: [
|
|
433
|
-
{ label: 'Allow once', value: 'once' },
|
|
434
|
-
{ label: 'Allow for session', value: 'session' },
|
|
435
|
-
{ label: 'Deny', value: 'deny' },
|
|
436
|
-
],
|
|
437
|
-
onSelect: (choice: NetworkQueryChoice) => resolveNetworkQuery(entry, choice),
|
|
438
|
-
onCancel: () => resolveNetworkQuery(entry, 'deny'),
|
|
439
|
-
onShow: () => notifyQuery(entry),
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
return api.ui.Prompt({
|
|
443
|
-
sessionID: props.session_id,
|
|
444
|
-
...(props.visible === undefined ? {} : { visible: props.visible }),
|
|
445
|
-
...(props.disabled === undefined ? {} : { disabled: props.disabled }),
|
|
446
|
-
...(props.on_submit === undefined ? {} : { onSubmit: props.on_submit }),
|
|
447
|
-
...(props.ref === undefined ? {} : { ref: props.ref }),
|
|
448
|
-
right: api.ui.Slot({
|
|
449
|
-
name: 'session_prompt_right',
|
|
450
|
-
session_id: props.session_id,
|
|
451
|
-
}),
|
|
452
|
-
});
|
|
453
|
-
},
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
function respondQuery(socket: NetSocket, queryId: string, action: 'allow' | 'deny'): void {
|
|
458
|
-
if (!socket.destroyed) socket.write(controlResponseLine(queryId, action));
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
function resolveFsQuery(entry: FsQueryEntry, choice: QueryChoice): void {
|
|
462
|
-
if (resolved.has(entry.id)) return;
|
|
463
|
-
const action = choice === 'deny' ? 'deny' : 'allow';
|
|
464
|
-
const verb = entry.operation === 'read' ? 'Read' : 'Write';
|
|
465
|
-
const directory = entry.directory;
|
|
466
|
-
const scope = sessionScopeFor(entry.path, directory);
|
|
467
|
-
const allowances = sessionAllowancesFor(sessionAllowances, entry.sessionID);
|
|
468
|
-
const sessionPaths = entry.operation === 'read' ? allowances.readPaths : allowances.writePaths;
|
|
469
|
-
|
|
470
|
-
let responseAction: 'allow' | 'deny' = action;
|
|
471
|
-
try {
|
|
472
|
-
if (action === 'allow') {
|
|
473
|
-
if (choice === 'project' || choice === 'global') {
|
|
474
|
-
const { globalPath, projectPath } = getConfigPaths(directory);
|
|
475
|
-
const update = updateForPermission({
|
|
476
|
-
permission: entry.operation,
|
|
477
|
-
metadata: { filepath: scope },
|
|
478
|
-
});
|
|
479
|
-
if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
|
|
480
|
-
}
|
|
481
|
-
if (choice !== 'once') sessionPaths.add(scope);
|
|
382
|
+
|
|
383
|
+
function finishActive(id: string): void {
|
|
384
|
+
resolved.add(id);
|
|
385
|
+
notified.delete(id);
|
|
386
|
+
if (activeId === id) {
|
|
387
|
+
activeId = undefined;
|
|
388
|
+
setActiveEntry(undefined);
|
|
482
389
|
}
|
|
483
|
-
|
|
484
|
-
responseAction = 'deny';
|
|
390
|
+
queueMicrotask(pump);
|
|
485
391
|
}
|
|
486
392
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
393
|
+
function notifyQuery(entry: QueueEntry): void {
|
|
394
|
+
if (notified.has(entry.id)) return;
|
|
395
|
+
notified.add(entry.id);
|
|
396
|
+
void context.attention
|
|
397
|
+
.notify({
|
|
398
|
+
title: `Sandbox ${entry.operation} blocked`,
|
|
399
|
+
message: entry.kind === 'fs-query' ? entry.path : entry.target,
|
|
400
|
+
sound: { name: 'permission' },
|
|
401
|
+
notification: true,
|
|
402
|
+
})
|
|
403
|
+
.catch(() => undefined);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function renderSessionPrompt(props: { sessionID: string }) {
|
|
407
|
+
return jsx(Fragment, {
|
|
408
|
+
get children() {
|
|
409
|
+
permissionRevision();
|
|
410
|
+
queueMicrotask(pump);
|
|
411
|
+
const entry = activeEntry();
|
|
412
|
+
const hostPromptActive = Boolean(entry && nativePermissionPending(entry));
|
|
413
|
+
const showEntry = Boolean(
|
|
414
|
+
entry &&
|
|
415
|
+
shouldRenderSandboxPermission(entry.sessionID, props.sessionID, hostPromptActive),
|
|
416
|
+
);
|
|
417
|
+
if (showEntry && entry?.kind === 'fs-query') {
|
|
418
|
+
const verb = entry.operation === 'read' ? 'Read' : 'Write';
|
|
419
|
+
return jsx(LandstripPermissionPrompt, {
|
|
420
|
+
icon: '→',
|
|
421
|
+
title: `${verb} ${formatPath(entry.path, entry.directory)}`,
|
|
422
|
+
options: [
|
|
423
|
+
{ label: 'Allow once', value: 'once' },
|
|
424
|
+
{ label: 'Allow for session', value: 'session' },
|
|
425
|
+
{ label: 'Allow for project', value: 'project' },
|
|
426
|
+
{ label: 'Allow globally', value: 'global' },
|
|
427
|
+
{ label: 'Deny', value: 'deny' },
|
|
428
|
+
],
|
|
429
|
+
onSelect: (choice: QueryChoice) => resolveFsQuery(entry, choice),
|
|
430
|
+
onCancel: () => resolveFsQuery(entry, 'deny'),
|
|
431
|
+
onShow: () => notifyQuery(entry),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
if (showEntry && entry?.kind === 'net-query') {
|
|
435
|
+
const verb = entry.operation
|
|
436
|
+
? entry.operation[0]?.toUpperCase() + entry.operation.slice(1)
|
|
437
|
+
: 'Network';
|
|
438
|
+
return jsx(LandstripPermissionPrompt, {
|
|
439
|
+
icon: '%',
|
|
440
|
+
title: `${verb} ${entry.target}`,
|
|
441
|
+
options: [
|
|
442
|
+
{ label: 'Allow once', value: 'once' },
|
|
443
|
+
{ label: 'Allow for session', value: 'session' },
|
|
444
|
+
{ label: 'Deny', value: 'deny' },
|
|
445
|
+
],
|
|
446
|
+
onSelect: (choice: NetworkQueryChoice) => resolveNetworkQuery(entry, choice),
|
|
447
|
+
onCancel: () => resolveNetworkQuery(entry, 'deny'),
|
|
448
|
+
onShow: () => notifyQuery(entry),
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
return null;
|
|
452
|
+
},
|
|
496
453
|
});
|
|
497
|
-
} finally {
|
|
498
|
-
liveQueries.delete(entry);
|
|
499
|
-
finishActive(entry.id);
|
|
500
454
|
}
|
|
501
|
-
}
|
|
502
455
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
456
|
+
function respondQuery(socket: NetSocket, queryId: string, action: 'allow' | 'deny'): void {
|
|
457
|
+
if (!socket.destroyed) socket.write(controlResponseLine(queryId, action));
|
|
458
|
+
}
|
|
506
459
|
|
|
507
|
-
|
|
508
|
-
if (
|
|
509
|
-
|
|
460
|
+
function resolveFsQuery(entry: FsQueryEntry, choice: QueryChoice): void {
|
|
461
|
+
if (resolved.has(entry.id)) return;
|
|
462
|
+
const action = choice === 'deny' ? 'deny' : 'allow';
|
|
463
|
+
const verb = entry.operation === 'read' ? 'Read' : 'Write';
|
|
464
|
+
const directory = entry.directory;
|
|
465
|
+
const scope = sessionScopeFor(entry.path, directory);
|
|
466
|
+
const allowances = sessionAllowancesFor(sessionAllowances, entry.sessionID);
|
|
467
|
+
const sessionPaths =
|
|
468
|
+
entry.operation === 'read' ? allowances.readPaths : allowances.writePaths;
|
|
469
|
+
|
|
470
|
+
let responseAction: 'allow' | 'deny' = action;
|
|
471
|
+
try {
|
|
472
|
+
if (action === 'allow') {
|
|
473
|
+
if (choice === 'project' || choice === 'global') {
|
|
474
|
+
const { globalPath, projectPath } = getConfigPaths(directory);
|
|
475
|
+
const update = updateForPermission({
|
|
476
|
+
permission: entry.operation,
|
|
477
|
+
metadata: { filepath: scope },
|
|
478
|
+
});
|
|
479
|
+
if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
|
|
480
|
+
}
|
|
481
|
+
if (choice !== 'once') sessionPaths.add(scope);
|
|
482
|
+
}
|
|
483
|
+
} catch {
|
|
484
|
+
responseAction = 'deny';
|
|
510
485
|
}
|
|
511
486
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
487
|
+
try {
|
|
488
|
+
respondQuery(entry.socket, entry.queryId, responseAction);
|
|
489
|
+
context.ui.toast.show({
|
|
490
|
+
title: 'Sandbox',
|
|
491
|
+
message:
|
|
492
|
+
responseAction === 'deny'
|
|
493
|
+
? `${verb} denied: ${entry.path}`
|
|
494
|
+
: `${verb} allowed (${choice}) under ${scope}`,
|
|
495
|
+
variant: responseAction === 'deny' ? 'warning' : 'success',
|
|
496
|
+
});
|
|
497
|
+
} finally {
|
|
498
|
+
liveQueries.delete(entry);
|
|
499
|
+
finishActive(entry.id);
|
|
500
|
+
}
|
|
524
501
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
if (process.platform === 'linux') {
|
|
534
|
-
const baseDirectory = api.state.path.directory || process.cwd();
|
|
535
|
-
let socketSeq = 0;
|
|
536
|
-
|
|
537
|
-
socketServer = createServer((socket) => {
|
|
538
|
-
sockets.add(socket);
|
|
539
|
-
socket.setEncoding('utf-8');
|
|
540
|
-
const socketId = ++socketSeq;
|
|
541
|
-
const seen = new Set<string>();
|
|
542
|
-
let buffer = '';
|
|
543
|
-
let sourceSessionID: string | undefined;
|
|
544
|
-
let routeSessionID: string | undefined;
|
|
545
|
-
let sessionDirectory: string | undefined;
|
|
546
|
-
|
|
547
|
-
socket.on('data', (chunk: string | Buffer) => {
|
|
548
|
-
buffer += chunk.toString();
|
|
549
|
-
if (buffer.length > 1024 * 1024) {
|
|
550
|
-
socket.destroy();
|
|
551
|
-
return;
|
|
502
|
+
|
|
503
|
+
function resolveNetworkQuery(entry: NetworkQueryEntry, choice: NetworkQueryChoice): void {
|
|
504
|
+
if (resolved.has(entry.id)) return;
|
|
505
|
+
const action = choice === 'deny' ? 'deny' : 'allow';
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
if (action === 'allow' && choice === 'session') {
|
|
509
|
+
sessionAllowancesFor(sessionAllowances, entry.sessionID).targets.add(entry.target);
|
|
552
510
|
}
|
|
553
511
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
}
|
|
569
|
-
sourceSessionID = hello.sessionID;
|
|
570
|
-
routeSessionID = rootSession;
|
|
571
|
-
sessionDirectory = sourceSession.directory || baseDirectory;
|
|
572
|
-
continue;
|
|
573
|
-
}
|
|
512
|
+
respondQuery(entry.socket, entry.queryId, action);
|
|
513
|
+
context.ui.toast.show({
|
|
514
|
+
title: 'Sandbox',
|
|
515
|
+
message:
|
|
516
|
+
action === 'deny'
|
|
517
|
+
? `${entry.operation} denied: ${entry.target}`
|
|
518
|
+
: `${entry.operation} allowed (${choice}): ${entry.target}`,
|
|
519
|
+
variant: action === 'deny' ? 'warning' : 'success',
|
|
520
|
+
});
|
|
521
|
+
} finally {
|
|
522
|
+
liveQueries.delete(entry);
|
|
523
|
+
finishActive(entry.id);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
574
526
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
527
|
+
// Query-response socket server (Linux-only — landstrip's socket protocol lives
|
|
528
|
+
// in the seccomp broker). The server plugin connects each sandboxed run's
|
|
529
|
+
// fd 3 here via a /dev/tcp redirect and we answer held writes interactively.
|
|
530
|
+
const sockets = new Set<NetSocket>();
|
|
531
|
+
let socketServer: ReturnType<typeof createServer> | undefined;
|
|
532
|
+
|
|
533
|
+
if (process.platform === 'linux') {
|
|
534
|
+
const baseDirectory = directory;
|
|
535
|
+
let socketSeq = 0;
|
|
536
|
+
|
|
537
|
+
socketServer = createServer((socket) => {
|
|
538
|
+
sockets.add(socket);
|
|
539
|
+
socket.setEncoding('utf-8');
|
|
540
|
+
const socketId = ++socketSeq;
|
|
541
|
+
const seen = new Set<string>();
|
|
542
|
+
let buffer = '';
|
|
543
|
+
let sourceSessionID: string | undefined;
|
|
544
|
+
let routeSessionID: string | undefined;
|
|
545
|
+
let sessionDirectory: string | undefined;
|
|
546
|
+
|
|
547
|
+
socket.on('data', (chunk: string | Buffer) => {
|
|
548
|
+
buffer += chunk.toString();
|
|
549
|
+
if (buffer.length > 1024 * 1024) {
|
|
550
|
+
socket.destroy();
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
581
553
|
|
|
582
|
-
|
|
583
|
-
|
|
554
|
+
let newline: number;
|
|
555
|
+
while ((newline = buffer.indexOf('\n')) !== -1) {
|
|
556
|
+
const line = buffer.slice(0, newline);
|
|
557
|
+
buffer = buffer.slice(newline + 1);
|
|
558
|
+
|
|
559
|
+
if (!sourceSessionID || !routeSessionID || !sessionDirectory) {
|
|
560
|
+
const hello = decodeTrapSessionHello(line);
|
|
561
|
+
const sourceSession = hello ? context.data.session.get(hello.sessionID) : undefined;
|
|
562
|
+
const rootSession = hello
|
|
563
|
+
? rootSessionIDFor(hello.sessionID, (sessionID) =>
|
|
564
|
+
context.data.session.get(sessionID),
|
|
565
|
+
)
|
|
566
|
+
: undefined;
|
|
567
|
+
if (!hello || !sourceSession || !rootSession) {
|
|
568
|
+
socket.destroy();
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
sourceSessionID = hello.sessionID;
|
|
572
|
+
routeSessionID = rootSession;
|
|
573
|
+
sessionDirectory = sourceSession.location.directory || directory;
|
|
584
574
|
continue;
|
|
585
575
|
}
|
|
586
576
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
trap.operation === 'read' ? allowances.readPaths : allowances.writePaths;
|
|
594
|
-
if (sessionAllows(sessionPaths, trap.path)) {
|
|
595
|
-
respondQuery(socket, trap.query_id, 'allow');
|
|
596
|
-
continue;
|
|
597
|
-
}
|
|
577
|
+
const allowances = sessionAllowancesFor(sessionAllowances, routeSessionID);
|
|
578
|
+
for (const trap of parseLandstripTraps(line)) {
|
|
579
|
+
if (trap.kind !== 'filesystem' && trap.kind !== 'network') continue;
|
|
580
|
+
if (trap.state !== 'query') continue;
|
|
581
|
+
if (seen.has(trap.query_id)) continue;
|
|
582
|
+
seen.add(trap.query_id);
|
|
598
583
|
|
|
599
|
-
|
|
600
|
-
kind: 'fs-query',
|
|
601
|
-
id: `landstrip-${trap.operation}:${socketId}:${trap.query_id}`,
|
|
602
|
-
socket,
|
|
603
|
-
queryId: trap.query_id,
|
|
604
|
-
operation: trap.operation,
|
|
605
|
-
path: trap.path,
|
|
606
|
-
sessionID: routeSessionID,
|
|
607
|
-
sourceSessionID,
|
|
608
|
-
directory: sessionDirectory,
|
|
609
|
-
};
|
|
610
|
-
liveQueries.add(entry);
|
|
611
|
-
enqueueEntry(entry);
|
|
612
|
-
} else {
|
|
613
|
-
if (allowances.targets.has(trap.target)) {
|
|
584
|
+
if (!loadConfig(sessionDirectory, optionOverrides).enabled) {
|
|
614
585
|
respondQuery(socket, trap.query_id, 'allow');
|
|
615
586
|
continue;
|
|
616
587
|
}
|
|
617
588
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
589
|
+
if (trap.kind === 'filesystem') {
|
|
590
|
+
if (trap.operation === 'write' && trap.reason === 'deny_match') {
|
|
591
|
+
respondQuery(socket, trap.query_id, 'deny');
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
const sessionPaths =
|
|
595
|
+
trap.operation === 'read' ? allowances.readPaths : allowances.writePaths;
|
|
596
|
+
if (sessionAllows(sessionPaths, trap.path)) {
|
|
597
|
+
respondQuery(socket, trap.query_id, 'allow');
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const entry: FsQueryEntry = {
|
|
602
|
+
kind: 'fs-query',
|
|
603
|
+
id: `landstrip-${trap.operation}:${socketId}:${trap.query_id}`,
|
|
604
|
+
socket,
|
|
605
|
+
queryId: trap.query_id,
|
|
606
|
+
operation: trap.operation,
|
|
607
|
+
path: trap.path,
|
|
608
|
+
sessionID: routeSessionID,
|
|
609
|
+
sourceSessionID,
|
|
610
|
+
directory: sessionDirectory,
|
|
611
|
+
};
|
|
612
|
+
liveQueries.add(entry);
|
|
613
|
+
enqueueEntry(entry);
|
|
614
|
+
} else {
|
|
615
|
+
if (allowances.targets.has(trap.target)) {
|
|
616
|
+
respondQuery(socket, trap.query_id, 'allow');
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const entry: NetworkQueryEntry = {
|
|
621
|
+
kind: 'net-query',
|
|
622
|
+
id: `landstrip-net:${socketId}:${trap.query_id}`,
|
|
623
|
+
socket,
|
|
624
|
+
queryId: trap.query_id,
|
|
625
|
+
operation: trap.operation,
|
|
626
|
+
target: trap.target,
|
|
627
|
+
sessionID: routeSessionID,
|
|
628
|
+
sourceSessionID,
|
|
629
|
+
directory: sessionDirectory,
|
|
630
|
+
};
|
|
631
|
+
liveQueries.add(entry);
|
|
632
|
+
enqueueEntry(entry);
|
|
633
|
+
}
|
|
631
634
|
}
|
|
632
635
|
}
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
const cleanup = () => {
|
|
639
|
+
sockets.delete(socket);
|
|
640
|
+
// The child is gone; drop our holds for this socket so the queue moves on.
|
|
641
|
+
// Deleting the current entry mid-iteration is well-defined for a Set.
|
|
642
|
+
for (const entry of liveQueries) {
|
|
643
|
+
if (entry.socket !== socket) continue;
|
|
644
|
+
liveQueries.delete(entry);
|
|
645
|
+
finishActive(entry.id);
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
socket.on('error', cleanup);
|
|
649
|
+
socket.on('close', cleanup);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
socketServer.on('error', () => {
|
|
653
|
+
try {
|
|
654
|
+
removeDiscoveryFile(baseDirectory);
|
|
655
|
+
} catch {
|
|
656
|
+
// best effort
|
|
633
657
|
}
|
|
634
658
|
});
|
|
635
659
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
660
|
+
socketServer.listen(0, '127.0.0.1', () => {
|
|
661
|
+
const address = socketServer?.address() as AddressInfo | null;
|
|
662
|
+
if (address && typeof address === 'object') {
|
|
663
|
+
try {
|
|
664
|
+
writeDiscoveryPort(baseDirectory, address.port);
|
|
665
|
+
} catch {
|
|
666
|
+
// best effort — falls back to the server's reset model
|
|
667
|
+
}
|
|
644
668
|
}
|
|
645
|
-
};
|
|
646
|
-
|
|
647
|
-
socket.on('close', cleanup);
|
|
648
|
-
});
|
|
669
|
+
});
|
|
670
|
+
}
|
|
649
671
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
672
|
+
const refreshPermissionPresentation = (): void => {
|
|
673
|
+
queueMicrotask(() => {
|
|
674
|
+
setPermissionRevision((revision) => revision + 1);
|
|
675
|
+
pump();
|
|
676
|
+
});
|
|
677
|
+
};
|
|
678
|
+
const unregisterPermissionAsked = context.data.on(
|
|
679
|
+
'permission.asked',
|
|
680
|
+
refreshPermissionPresentation,
|
|
681
|
+
);
|
|
682
|
+
const unregisterPermissionReplied = context.data.on(
|
|
683
|
+
'permission.replied',
|
|
684
|
+
refreshPermissionPresentation,
|
|
685
|
+
);
|
|
657
686
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
687
|
+
const [landstripOpen, setLandstripOpen] = createSignal(false);
|
|
688
|
+
const [confirmingDisable, setConfirmingDisable] = createSignal(false);
|
|
689
|
+
const [sandboxRevision, setSandboxRevision] = createSignal(0);
|
|
690
|
+
let popLandstripMode: (() => void) | undefined;
|
|
691
|
+
|
|
692
|
+
const closeLandstrip = () => {
|
|
693
|
+
popLandstripMode?.();
|
|
694
|
+
popLandstripMode = undefined;
|
|
695
|
+
setConfirmingDisable(false);
|
|
696
|
+
setLandstripOpen(false);
|
|
697
|
+
};
|
|
698
|
+
const toggleLandstrip = () => {
|
|
699
|
+
if (enabledManagedByOptions) {
|
|
700
|
+
context.ui.toast.show({
|
|
701
|
+
title: 'Landstrip',
|
|
702
|
+
message: 'Sandbox state is managed by plugin options',
|
|
703
|
+
variant: 'warning',
|
|
704
|
+
});
|
|
705
|
+
return;
|
|
666
706
|
}
|
|
667
|
-
});
|
|
668
|
-
}
|
|
669
707
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
);
|
|
681
|
-
|
|
682
|
-
const [landstripOpen, setLandstripOpen] = createSignal(false);
|
|
683
|
-
const [confirmingDisable, setConfirmingDisable] = createSignal(false);
|
|
684
|
-
const [sandboxRevision, setSandboxRevision] = createSignal(0);
|
|
685
|
-
let unregisterLandstripKeys: (() => void) | undefined;
|
|
686
|
-
let popLandstripMode: (() => void) | undefined;
|
|
687
|
-
|
|
688
|
-
const closeLandstrip = () => {
|
|
689
|
-
unregisterLandstripKeys?.();
|
|
690
|
-
popLandstripMode?.();
|
|
691
|
-
unregisterLandstripKeys = undefined;
|
|
692
|
-
popLandstripMode = undefined;
|
|
693
|
-
setConfirmingDisable(false);
|
|
694
|
-
setLandstripOpen(false);
|
|
695
|
-
};
|
|
696
|
-
const toggleLandstrip = () => {
|
|
697
|
-
if (enabledManagedByOptions) {
|
|
698
|
-
api.ui.toast({
|
|
708
|
+
const config = loadConfig(directory, optionOverrides);
|
|
709
|
+
if (config.enabled && !confirmingDisable()) {
|
|
710
|
+
setConfirmingDisable(true);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
setConfirmingDisable(false);
|
|
714
|
+
const enabled = !config.enabled;
|
|
715
|
+
const scope = setSandboxConfigEnabled(directory, enabled, optionOverrides);
|
|
716
|
+
refreshSandboxStatus?.();
|
|
717
|
+
context.ui.toast.show({
|
|
699
718
|
title: 'Landstrip',
|
|
700
|
-
message:
|
|
701
|
-
variant: 'warning',
|
|
719
|
+
message: `Sandbox ${enabled ? 'enabled' : 'disabled'} (${scope} config)`,
|
|
720
|
+
variant: enabled ? 'success' : 'warning',
|
|
702
721
|
});
|
|
703
|
-
|
|
704
|
-
|
|
722
|
+
};
|
|
723
|
+
const cancelLandstrip = () => {
|
|
724
|
+
if (confirmingDisable()) setConfirmingDisable(false);
|
|
725
|
+
else closeLandstrip();
|
|
726
|
+
};
|
|
705
727
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
setConfirmingDisable(true);
|
|
710
|
-
return;
|
|
711
|
-
}
|
|
712
|
-
setConfirmingDisable(false);
|
|
713
|
-
const enabled = !config.enabled;
|
|
714
|
-
const scope = setSandboxConfigEnabled(directory, enabled, optionOverrides);
|
|
715
|
-
refreshSandboxStatus?.();
|
|
716
|
-
api.ui.toast({
|
|
717
|
-
title: 'Landstrip',
|
|
718
|
-
message: `Sandbox ${enabled ? 'enabled' : 'disabled'} (${scope} config)`,
|
|
719
|
-
variant: enabled ? 'success' : 'warning',
|
|
720
|
-
});
|
|
721
|
-
};
|
|
722
|
-
const cancelLandstrip = () => {
|
|
723
|
-
if (confirmingDisable()) setConfirmingDisable(false);
|
|
724
|
-
else closeLandstrip();
|
|
725
|
-
};
|
|
728
|
+
function LandstripPane() {
|
|
729
|
+
const theme = resolveThemeColors(context.theme);
|
|
730
|
+
const dimensions = useTerminalDimensions();
|
|
726
731
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
flexDirection: 'row',
|
|
734
|
-
paddingLeft: 2,
|
|
735
|
-
paddingRight: 2,
|
|
736
|
-
children: [
|
|
737
|
-
jsx('text', { fg: theme.textMuted, width: 22, children: label }),
|
|
738
|
-
jsx('text', { fg: theme.text, flexGrow: 1, wrapMode: 'word', children: value }),
|
|
732
|
+
context.keymap.layer(() => ({
|
|
733
|
+
enabled: () => landstripOpen(),
|
|
734
|
+
priority: 1000,
|
|
735
|
+
commands: [
|
|
736
|
+
{ id: 'landstrip-toggle', bind: 'return', run: toggleLandstrip },
|
|
737
|
+
{ id: 'landstrip-cancel', bind: 'escape', run: cancelLandstrip },
|
|
739
738
|
],
|
|
740
|
-
});
|
|
739
|
+
}));
|
|
741
740
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
const config = loadConfig(directory, optionOverrides);
|
|
748
|
-
const target = sandboxConfigTarget(directory);
|
|
749
|
-
const values = (items: readonly string[]) => items.join(', ') || 'none';
|
|
750
|
-
const status = enabledManagedByOptions
|
|
751
|
-
? config.enabled
|
|
752
|
-
? 'Active (plugin options)'
|
|
753
|
-
: 'Disabled by plugin options'
|
|
754
|
-
: config.enabled
|
|
755
|
-
? 'Active'
|
|
756
|
-
: 'Disabled by configuration';
|
|
757
|
-
return jsxs('box', {
|
|
758
|
-
position: 'absolute',
|
|
759
|
-
zIndex: 4000,
|
|
760
|
-
bottom: 0,
|
|
761
|
-
left: 0,
|
|
762
|
-
right: 0,
|
|
763
|
-
get height() {
|
|
764
|
-
return Math.min(22, Math.max(12, dimensions().height - 2));
|
|
765
|
-
},
|
|
766
|
-
flexDirection: 'column',
|
|
767
|
-
border: ['top'],
|
|
768
|
-
borderColor: theme.primary,
|
|
769
|
-
backgroundColor: theme.backgroundPanel,
|
|
741
|
+
const row = (label: string, value: string) =>
|
|
742
|
+
jsxs('box', {
|
|
743
|
+
flexDirection: 'row',
|
|
744
|
+
paddingLeft: 2,
|
|
745
|
+
paddingRight: 2,
|
|
770
746
|
children: [
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
paddingLeft: 2,
|
|
774
|
-
paddingRight: 2,
|
|
775
|
-
paddingTop: 1,
|
|
776
|
-
paddingBottom: 1,
|
|
777
|
-
justifyContent: 'space-between',
|
|
778
|
-
children: [
|
|
779
|
-
jsx('text', { fg: theme.primary, children: 'Landstrip' }),
|
|
780
|
-
jsx('text', {
|
|
781
|
-
fg: config.enabled ? theme.success : theme.warning,
|
|
782
|
-
children: status,
|
|
783
|
-
}),
|
|
784
|
-
],
|
|
785
|
-
}),
|
|
786
|
-
row('Network', config.network.allowNetwork ? 'Unrestricted' : 'Proxied'),
|
|
787
|
-
row('Allowed Domains', values(config.network.allowedDomains)),
|
|
788
|
-
row('Denied Domains', values(config.network.deniedDomains)),
|
|
789
|
-
row('Allowed Reads', values(config.filesystem.allowRead)),
|
|
790
|
-
row('Denied Reads', values(config.filesystem.denyRead)),
|
|
791
|
-
row('Allowed Writes', values(config.filesystem.allowWrite)),
|
|
792
|
-
row('Denied Writes', values(config.filesystem.denyWrite)),
|
|
793
|
-
row(
|
|
794
|
-
'Configuration Scope',
|
|
795
|
-
enabledManagedByOptions
|
|
796
|
-
? 'Plugin options'
|
|
797
|
-
: target.scope === 'project'
|
|
798
|
-
? 'Project'
|
|
799
|
-
: 'Global',
|
|
800
|
-
),
|
|
801
|
-
row('Configuration File', formatPath(target.path, directory)),
|
|
802
|
-
jsx('box', { flexGrow: 1 }),
|
|
803
|
-
jsx('text', {
|
|
804
|
-
fg: confirmingDisable() ? theme.error : theme.textMuted,
|
|
805
|
-
marginLeft: 2,
|
|
806
|
-
marginRight: 2,
|
|
807
|
-
marginBottom: 1,
|
|
808
|
-
children: confirmingDisable()
|
|
809
|
-
? 'Disable the sandbox? Commands will run without OS isolation. Enter confirm · Esc cancel'
|
|
810
|
-
: enabledManagedByOptions
|
|
811
|
-
? 'Sandbox state is managed by plugin options · Esc close'
|
|
812
|
-
: `Enter ${config.enabled ? 'disable' : 'enable'} · Esc close`,
|
|
813
|
-
}),
|
|
747
|
+
jsx('text', { fg: theme.textMuted, width: 22, children: label }),
|
|
748
|
+
jsx('text', { fg: theme.text, flexGrow: 1, wrapMode: 'word', children: value }),
|
|
814
749
|
],
|
|
815
750
|
});
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
751
|
+
|
|
752
|
+
return Portal({
|
|
753
|
+
get children() {
|
|
754
|
+
if (!landstripOpen()) return null;
|
|
755
|
+
sandboxRevision();
|
|
756
|
+
const config = loadConfig(directory, optionOverrides);
|
|
757
|
+
const target = sandboxConfigTarget(directory);
|
|
758
|
+
const values = (items: readonly string[]) => items.join(', ') || 'none';
|
|
759
|
+
const status = enabledManagedByOptions
|
|
760
|
+
? config.enabled
|
|
761
|
+
? 'Active (plugin options)'
|
|
762
|
+
: 'Disabled by plugin options'
|
|
763
|
+
: config.enabled
|
|
764
|
+
? 'Active'
|
|
765
|
+
: 'Disabled by configuration';
|
|
766
|
+
return jsxs('box', {
|
|
767
|
+
position: 'absolute',
|
|
768
|
+
zIndex: 4000,
|
|
769
|
+
bottom: 0,
|
|
770
|
+
left: 0,
|
|
771
|
+
right: 0,
|
|
772
|
+
get height() {
|
|
773
|
+
return Math.min(22, Math.max(12, dimensions().height - 2));
|
|
774
|
+
},
|
|
775
|
+
flexDirection: 'column',
|
|
776
|
+
border: ['top'],
|
|
777
|
+
borderColor: theme.primary,
|
|
778
|
+
backgroundColor: theme.backgroundPanel,
|
|
779
|
+
children: [
|
|
780
|
+
jsxs('box', {
|
|
781
|
+
flexDirection: 'row',
|
|
782
|
+
paddingLeft: 2,
|
|
783
|
+
paddingRight: 2,
|
|
784
|
+
paddingTop: 1,
|
|
785
|
+
paddingBottom: 1,
|
|
786
|
+
justifyContent: 'space-between',
|
|
787
|
+
children: [
|
|
788
|
+
jsx('text', { fg: theme.primary, children: 'Landstrip' }),
|
|
789
|
+
jsx('text', {
|
|
790
|
+
fg: config.enabled ? theme.success : theme.warning,
|
|
791
|
+
children: status,
|
|
792
|
+
}),
|
|
793
|
+
],
|
|
794
|
+
}),
|
|
795
|
+
row('Network', config.network.allowNetwork ? 'Unrestricted' : 'Proxied'),
|
|
796
|
+
row('Allowed Domains', values(config.network.allowedDomains)),
|
|
797
|
+
row('Denied Domains', values(config.network.deniedDomains)),
|
|
798
|
+
row('Allowed Reads', values(config.filesystem.allowRead)),
|
|
799
|
+
row('Denied Reads', values(config.filesystem.denyRead)),
|
|
800
|
+
row('Allowed Writes', values(config.filesystem.allowWrite)),
|
|
801
|
+
row('Denied Writes', values(config.filesystem.denyWrite)),
|
|
802
|
+
row(
|
|
803
|
+
'Configuration Scope',
|
|
804
|
+
enabledManagedByOptions
|
|
805
|
+
? 'Plugin options'
|
|
806
|
+
: target.scope === 'project'
|
|
807
|
+
? 'Project'
|
|
808
|
+
: 'Global',
|
|
809
|
+
),
|
|
810
|
+
row('Configuration File', formatPath(target.path, directory)),
|
|
811
|
+
jsx('box', { flexGrow: 1 }),
|
|
812
|
+
jsx('text', {
|
|
813
|
+
fg: confirmingDisable() ? theme.error : theme.textMuted,
|
|
814
|
+
marginLeft: 2,
|
|
815
|
+
marginRight: 2,
|
|
816
|
+
marginBottom: 1,
|
|
817
|
+
children: confirmingDisable()
|
|
818
|
+
? 'Disable the sandbox? Commands will run without OS isolation. Enter confirm · Esc cancel'
|
|
819
|
+
: enabledManagedByOptions
|
|
820
|
+
? 'Sandbox state is managed by plugin options · Esc close'
|
|
821
|
+
: `Enter ${config.enabled ? 'disable' : 'enable'} · Esc close`,
|
|
822
|
+
}),
|
|
823
|
+
],
|
|
824
|
+
});
|
|
825
|
+
},
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const showLandstrip = () => {
|
|
830
|
+
if (landstripOpen()) return;
|
|
831
|
+
setLandstripOpen(true);
|
|
832
|
+
popLandstripMode = context.keymap.mode.push('modal');
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
context.keymap.layer(() => ({
|
|
836
|
+
commands: [
|
|
837
|
+
{
|
|
838
|
+
id: 'landstrip',
|
|
839
|
+
title: 'Landstrip',
|
|
840
|
+
description: 'Manage the Landstrip sandbox',
|
|
841
|
+
palette: true,
|
|
842
|
+
suggested: true,
|
|
843
|
+
slash: { name: 'landstrip' },
|
|
844
|
+
run: showLandstrip,
|
|
845
|
+
},
|
|
829
846
|
],
|
|
830
|
-
});
|
|
831
|
-
};
|
|
832
|
-
api.lifecycle.onDispose(closeLandstrip);
|
|
847
|
+
}));
|
|
833
848
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
{
|
|
837
|
-
namespace: 'palette',
|
|
838
|
-
name: 'landstrip',
|
|
839
|
-
title: 'Landstrip',
|
|
840
|
-
desc: 'Manage the Landstrip sandbox',
|
|
841
|
-
category: 'Landstrip',
|
|
842
|
-
suggested: true,
|
|
843
|
-
slash: { name: 'landstrip' },
|
|
844
|
-
slashName: 'landstrip',
|
|
845
|
-
run: showLandstrip,
|
|
846
|
-
},
|
|
847
|
-
],
|
|
848
|
-
});
|
|
849
|
-
|
|
850
|
-
// Persistent status badge in the prompt area.
|
|
851
|
-
try {
|
|
852
|
-
const statusBadge = (ctx: TuiSlotContext) => {
|
|
849
|
+
// Persistent status badge in the prompt area.
|
|
850
|
+
const statusBadge = () => {
|
|
853
851
|
sandboxRevision();
|
|
854
|
-
const directory = api.state.path.directory || process.cwd();
|
|
855
852
|
const config = loadConfig(directory, optionOverrides);
|
|
856
|
-
const theme =
|
|
853
|
+
const theme = resolveThemeColors(context.theme);
|
|
857
854
|
|
|
858
855
|
if (!config.enabled) return jsx('text', { fg: theme.textMuted, children: 'sandbox off' });
|
|
859
856
|
|
|
@@ -864,55 +861,70 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
864
861
|
});
|
|
865
862
|
};
|
|
866
863
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
home_prompt_right: (ctx) => statusBadge(ctx),
|
|
871
|
-
session_prompt: (_ctx, props) => renderSessionPrompt(props),
|
|
872
|
-
session_prompt_right: (ctx) => statusBadge(ctx),
|
|
873
|
-
},
|
|
874
|
-
};
|
|
875
|
-
api.slots.register(statusSlot);
|
|
876
|
-
refreshSandboxStatus = () => setSandboxRevision((revision) => revision + 1);
|
|
877
|
-
} catch {
|
|
878
|
-
// Solid runtime unavailable on this host — skip the pane and status badge.
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
// First-run onboarding: a single quiet pointer to the default-strict policy
|
|
882
|
-
// and the inspector command. `meta.state` flags a freshly installed plugin;
|
|
883
|
-
// the kv flag keeps it from repeating across reloads.
|
|
884
|
-
if (meta.state === 'first' && !api.kv.get<boolean>('onboarded', false)) {
|
|
885
|
-
api.kv.set('onboarded', true);
|
|
886
|
-
api.ui.toast({
|
|
887
|
-
title: 'Sandbox active',
|
|
888
|
-
message: 'Landstrip sandbox is on. Run /landstrip to inspect it.',
|
|
889
|
-
variant: 'info',
|
|
890
|
-
duration: 8000,
|
|
864
|
+
context.ui.slot({
|
|
865
|
+
append: 'app',
|
|
866
|
+
render: () => jsx(LandstripPane, {}),
|
|
891
867
|
});
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
868
|
+
context.ui.slot({
|
|
869
|
+
append: 'home.footer.status',
|
|
870
|
+
render: () => statusBadge(),
|
|
871
|
+
});
|
|
872
|
+
context.ui.slot({
|
|
873
|
+
append: 'prompt.footer.status',
|
|
874
|
+
render: () => statusBadge(),
|
|
875
|
+
});
|
|
876
|
+
context.ui.slot({
|
|
877
|
+
append: 'session.composer.top',
|
|
878
|
+
render: (props) => renderSessionPrompt(props),
|
|
879
|
+
});
|
|
880
|
+
refreshSandboxStatus = () => setSandboxRevision((revision) => revision + 1);
|
|
881
|
+
|
|
882
|
+
// First-run onboarding: a single quiet pointer to the default-strict policy
|
|
883
|
+
// and the inspector command.
|
|
884
|
+
try {
|
|
885
|
+
const [persisted, setPersisted] = context.storage.store<{ onboarded?: boolean }>(
|
|
886
|
+
'opencode-landstrip',
|
|
887
|
+
{ initial: { onboarded: false } },
|
|
888
|
+
);
|
|
889
|
+
if (!persisted.onboarded) {
|
|
890
|
+
void setPersisted((draft) => {
|
|
891
|
+
draft.onboarded = true;
|
|
892
|
+
}).catch(() => undefined);
|
|
893
|
+
context.ui.toast.show({
|
|
894
|
+
title: 'Sandbox active',
|
|
895
|
+
message: 'Landstrip sandbox is on. Run /landstrip to inspect it.',
|
|
896
|
+
variant: 'info',
|
|
897
|
+
duration: 8000,
|
|
898
|
+
});
|
|
912
899
|
}
|
|
900
|
+
} catch {
|
|
901
|
+
// Ignore storage errors
|
|
913
902
|
}
|
|
914
|
-
|
|
903
|
+
|
|
904
|
+
return () => {
|
|
905
|
+
closeLandstrip();
|
|
906
|
+
unregisterPermissionAsked();
|
|
907
|
+
unregisterPermissionReplied();
|
|
908
|
+
sessionAllowances.clear();
|
|
909
|
+
// Deny any still-held queries so the sandboxed children don't hang, then
|
|
910
|
+
// tear down the socket server and drop the discovery file.
|
|
911
|
+
for (const entry of liveQueries) {
|
|
912
|
+
respondQuery(entry.socket, entry.queryId, 'deny');
|
|
913
|
+
liveQueries.delete(entry);
|
|
914
|
+
finishActive(entry.id);
|
|
915
|
+
}
|
|
916
|
+
for (const socket of sockets) socket.destroy();
|
|
917
|
+
if (socketServer) {
|
|
918
|
+
socketServer.close();
|
|
919
|
+
try {
|
|
920
|
+
removeDiscoveryFile(directory);
|
|
921
|
+
} catch {
|
|
922
|
+
// best effort
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
},
|
|
915
927
|
};
|
|
916
928
|
|
|
917
929
|
export { tui };
|
|
918
|
-
export default
|
|
930
|
+
export default tui;
|