opencode-landstrip 0.14.0 → 0.14.2
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/index.ts +57 -12
- package/package.json +1 -1
- package/tui.ts +17 -12
package/index.ts
CHANGED
|
@@ -58,7 +58,7 @@ interface SandboxPermissionDecision {
|
|
|
58
58
|
|
|
59
59
|
type ToastVariant = 'info' | 'success' | 'warning' | 'error';
|
|
60
60
|
|
|
61
|
-
const LANDSTRIP_VERSION = [0, 14,
|
|
61
|
+
const LANDSTRIP_VERSION = [0, 14, 5] as const;
|
|
62
62
|
const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
|
|
63
63
|
const LANDSTRIP_OPERATIONS = new Set<'read' | 'write'>(['read', 'write']);
|
|
64
64
|
const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
|
|
@@ -101,6 +101,38 @@ function canonicalizePath(filePath: string, baseDirectory: string): string {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Translates an absolute glob pattern to a regular expression using standard
|
|
106
|
+
* path semantics: `**` crosses directory boundaries (and `**/` may match zero
|
|
107
|
+
* segments), while a single `*` is confined to one path segment.
|
|
108
|
+
*/
|
|
109
|
+
function globToRegExp(globPattern: string): RegExp {
|
|
110
|
+
let regex = '';
|
|
111
|
+
|
|
112
|
+
for (let i = 0; i < globPattern.length; i++) {
|
|
113
|
+
const char = globPattern[i];
|
|
114
|
+
if (char === '*') {
|
|
115
|
+
if (globPattern[i + 1] === '*') {
|
|
116
|
+
i++;
|
|
117
|
+
if (globPattern[i + 1] === '/') {
|
|
118
|
+
i++;
|
|
119
|
+
regex += '(?:.*/)?';
|
|
120
|
+
} else {
|
|
121
|
+
regex += '.*';
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
regex += '[^/]*';
|
|
125
|
+
}
|
|
126
|
+
} else if (/[.+^${}()|[\]\\]/.test(char)) {
|
|
127
|
+
regex += `\\${char}`;
|
|
128
|
+
} else {
|
|
129
|
+
regex += char;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return new RegExp(`^${regex}$`);
|
|
134
|
+
}
|
|
135
|
+
|
|
104
136
|
function matchesPattern(filePath: string, patterns: string[], baseDirectory: string): boolean {
|
|
105
137
|
const abs = canonicalizePath(filePath, baseDirectory);
|
|
106
138
|
|
|
@@ -110,8 +142,7 @@ function matchesPattern(filePath: string, patterns: string[], baseDirectory: str
|
|
|
110
142
|
: canonicalizePath(pattern, baseDirectory);
|
|
111
143
|
|
|
112
144
|
if (pattern.includes('*')) {
|
|
113
|
-
|
|
114
|
-
return new RegExp(`^${escaped}$`).test(abs);
|
|
145
|
+
return globToRegExp(absPattern).test(abs);
|
|
115
146
|
}
|
|
116
147
|
|
|
117
148
|
const sep = absPattern.endsWith('/') ? '' : '/';
|
|
@@ -271,10 +302,6 @@ function evaluateReadPermission(
|
|
|
271
302
|
): SandboxPermissionDecision {
|
|
272
303
|
const filePath = canonicalizePath(path, baseDirectory);
|
|
273
304
|
|
|
274
|
-
if (!shouldPromptForRead(filePath, effectiveAllowRead, baseDirectory)) {
|
|
275
|
-
return { status: 'allow', kind: 'read', resource: filePath, message: '' };
|
|
276
|
-
}
|
|
277
|
-
|
|
278
305
|
if (isBlockedByDenyRead(filePath, config, baseDirectory)) {
|
|
279
306
|
return {
|
|
280
307
|
status: 'deny',
|
|
@@ -284,6 +311,10 @@ function evaluateReadPermission(
|
|
|
284
311
|
};
|
|
285
312
|
}
|
|
286
313
|
|
|
314
|
+
if (!shouldPromptForRead(filePath, effectiveAllowRead, baseDirectory)) {
|
|
315
|
+
return { status: 'allow', kind: 'read', resource: filePath, message: '' };
|
|
316
|
+
}
|
|
317
|
+
|
|
287
318
|
return {
|
|
288
319
|
status: 'ask',
|
|
289
320
|
kind: 'read',
|
|
@@ -300,10 +331,6 @@ function evaluateWritePermission(
|
|
|
300
331
|
): SandboxPermissionDecision {
|
|
301
332
|
const filePath = canonicalizePath(path, baseDirectory);
|
|
302
333
|
|
|
303
|
-
if (!shouldPromptForWrite(filePath, effectiveAllowWrite, baseDirectory)) {
|
|
304
|
-
return { status: 'allow', kind: 'write', resource: filePath, message: '' };
|
|
305
|
-
}
|
|
306
|
-
|
|
307
334
|
if (matchesPattern(filePath, config.filesystem.denyWrite, baseDirectory)) {
|
|
308
335
|
return {
|
|
309
336
|
status: 'deny',
|
|
@@ -313,6 +340,10 @@ function evaluateWritePermission(
|
|
|
313
340
|
};
|
|
314
341
|
}
|
|
315
342
|
|
|
343
|
+
if (!shouldPromptForWrite(filePath, effectiveAllowWrite, baseDirectory)) {
|
|
344
|
+
return { status: 'allow', kind: 'write', resource: filePath, message: '' };
|
|
345
|
+
}
|
|
346
|
+
|
|
316
347
|
return {
|
|
317
348
|
status: 'ask',
|
|
318
349
|
kind: 'write',
|
|
@@ -325,7 +356,7 @@ function evaluateDomainPermission(
|
|
|
325
356
|
domain: string,
|
|
326
357
|
config: SandboxConfig,
|
|
327
358
|
): SandboxPermissionDecision {
|
|
328
|
-
if (config.network.allowNetwork
|
|
359
|
+
if (config.network.allowNetwork) {
|
|
329
360
|
return { status: 'allow', kind: 'domain', resource: domain, message: '' };
|
|
330
361
|
}
|
|
331
362
|
|
|
@@ -338,6 +369,10 @@ function evaluateDomainPermission(
|
|
|
338
369
|
};
|
|
339
370
|
}
|
|
340
371
|
|
|
372
|
+
if (domainMatchesAny(domain, config.network.allowedDomains)) {
|
|
373
|
+
return { status: 'allow', kind: 'domain', resource: domain, message: '' };
|
|
374
|
+
}
|
|
375
|
+
|
|
341
376
|
return {
|
|
342
377
|
status: 'ask',
|
|
343
378
|
kind: 'domain',
|
|
@@ -547,10 +582,15 @@ function startProxy(config: SandboxConfig): Promise<{ port: number; stop: () =>
|
|
|
547
582
|
return;
|
|
548
583
|
}
|
|
549
584
|
|
|
585
|
+
let connected = false;
|
|
550
586
|
const upstream = connectNet(endpoint.port, endpoint.host, () => {
|
|
587
|
+
connected = true;
|
|
551
588
|
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
552
589
|
pipeSockets(client, upstream, rest);
|
|
553
590
|
});
|
|
591
|
+
upstream.on('error', () => {
|
|
592
|
+
if (!connected) denyProxyRequest(client, '502 Bad Gateway');
|
|
593
|
+
});
|
|
554
594
|
}
|
|
555
595
|
|
|
556
596
|
async function handleHttp(client: Socket, headerText: string, rest: Buffer): Promise<void> {
|
|
@@ -589,10 +629,15 @@ function startProxy(config: SandboxConfig): Promise<{ port: number; stop: () =>
|
|
|
589
629
|
const rewrittenHeader = lines
|
|
590
630
|
.filter((line) => !line.toLowerCase().startsWith('proxy-connection:'))
|
|
591
631
|
.join('\r\n');
|
|
632
|
+
let connected = false;
|
|
592
633
|
const upstream = connectNet(port, url.hostname, () => {
|
|
634
|
+
connected = true;
|
|
593
635
|
upstream.write(`${rewrittenHeader}\r\n\r\n`);
|
|
594
636
|
pipeSockets(client, upstream, rest);
|
|
595
637
|
});
|
|
638
|
+
upstream.on('error', () => {
|
|
639
|
+
if (!connected) denyProxyRequest(client, '502 Bad Gateway');
|
|
640
|
+
});
|
|
596
641
|
}
|
|
597
642
|
|
|
598
643
|
function handleClient(client: Socket): void {
|
package/package.json
CHANGED
package/tui.ts
CHANGED
|
@@ -64,7 +64,7 @@ function sandboxSummary(baseDirectory: string, optionOverrides: SandboxConfigOve
|
|
|
64
64
|
`allow write: ${list(config.filesystem.allowWrite)}`,
|
|
65
65
|
`deny write: ${list(config.filesystem.denyWrite)}`,
|
|
66
66
|
'',
|
|
67
|
-
'esc or
|
|
67
|
+
'Press esc or enter to close',
|
|
68
68
|
].join('\n');
|
|
69
69
|
}
|
|
70
70
|
|
|
@@ -118,7 +118,10 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
118
118
|
activeId = undefined;
|
|
119
119
|
api.ui.dialog.clear();
|
|
120
120
|
}
|
|
121
|
-
|
|
121
|
+
// Defer: `clear()` above tears the dialog down by calling its `onClose`,
|
|
122
|
+
// and the host pops the stack asynchronously. Opening the next dialog
|
|
123
|
+
// synchronously here would race that teardown and get wiped.
|
|
124
|
+
queueMicrotask(pump);
|
|
122
125
|
}
|
|
123
126
|
|
|
124
127
|
async function replyPermission(
|
|
@@ -212,9 +215,11 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
212
215
|
() => {
|
|
213
216
|
// Dialog dismissed (esc) without a choice: drop our hold so the next
|
|
214
217
|
// pending permission can surface, but leave it unresolved upstream.
|
|
218
|
+
// The host pops the dialog itself; calling `clear()` here would re-enter
|
|
219
|
+
// this `onClose` (clear() invokes every entry's onClose) and loop until
|
|
220
|
+
// the stack overflows. Defer `pump()` so the pop settles first.
|
|
215
221
|
if (activeId === permission.id) activeId = undefined;
|
|
216
|
-
|
|
217
|
-
pump();
|
|
222
|
+
queueMicrotask(pump);
|
|
218
223
|
},
|
|
219
224
|
);
|
|
220
225
|
}
|
|
@@ -233,14 +238,14 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
233
238
|
const directory = api.state.path.directory || process.cwd();
|
|
234
239
|
const message = sandboxSummary(directory, optionOverrides);
|
|
235
240
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
241
|
+
// No `onConfirm`/`onClose` that call `clear()`: the host already pops the
|
|
242
|
+
// dialog on enter/esc/click, and its `clear()` re-invokes every entry's
|
|
243
|
+
// `onClose`, so a `clear()` in there recurses forever and freezes the TUI.
|
|
244
|
+
api.ui.dialog.replace(() =>
|
|
245
|
+
api.ui.DialogAlert({
|
|
246
|
+
title: 'Sandbox Configuration',
|
|
247
|
+
message,
|
|
248
|
+
}),
|
|
244
249
|
);
|
|
245
250
|
};
|
|
246
251
|
|