opencode-landstrip 0.16.4 → 0.16.6
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 +16 -37
- package/package.json +1 -1
- package/shared.ts +75 -18
- package/tui.ts +39 -40
package/index.ts
CHANGED
|
@@ -11,7 +11,6 @@ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
|
11
11
|
import { URL } from 'node:url';
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
|
-
list,
|
|
15
14
|
type LandstripTrap,
|
|
16
15
|
type SandboxConfig,
|
|
17
16
|
type SandboxFilesystemConfig,
|
|
@@ -26,6 +25,7 @@ import {
|
|
|
26
25
|
permissionPatterns,
|
|
27
26
|
permissionType,
|
|
28
27
|
readDiscoveryPort,
|
|
28
|
+
sandboxSummary,
|
|
29
29
|
} from './shared.js';
|
|
30
30
|
|
|
31
31
|
interface LandstripPolicy {
|
|
@@ -96,12 +96,17 @@ function canonicalizePath(filePath: string, baseDirectory: string): string {
|
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
const globRegExpCache = new Map<string, RegExp>();
|
|
100
|
+
|
|
99
101
|
/**
|
|
100
102
|
* Translates an absolute glob pattern to a regular expression using standard
|
|
101
103
|
* path semantics: `**` crosses directory boundaries (and `**/` may match zero
|
|
102
104
|
* segments), while a single `*` is confined to one path segment.
|
|
103
105
|
*/
|
|
104
106
|
function globToRegExp(globPattern: string): RegExp {
|
|
107
|
+
const cached = globRegExpCache.get(globPattern);
|
|
108
|
+
if (cached) return cached;
|
|
109
|
+
|
|
105
110
|
let regex = '';
|
|
106
111
|
|
|
107
112
|
for (let i = 0; i < globPattern.length; i++) {
|
|
@@ -125,7 +130,9 @@ function globToRegExp(globPattern: string): RegExp {
|
|
|
125
130
|
}
|
|
126
131
|
}
|
|
127
132
|
|
|
128
|
-
|
|
133
|
+
const result = new RegExp(`^${regex}$`);
|
|
134
|
+
globRegExpCache.set(globPattern, result);
|
|
135
|
+
return result;
|
|
129
136
|
}
|
|
130
137
|
|
|
131
138
|
// Component count of an absolute path; "/" is 0. Used to rank how specific a
|
|
@@ -708,10 +715,8 @@ function splitShellQuotedArgs(command: string): string[] {
|
|
|
708
715
|
function extractOriginalCommand(wrappedCommand: string): string | null {
|
|
709
716
|
const args = splitShellQuotedArgs(wrappedCommand);
|
|
710
717
|
const pIdx = args.indexOf('-p');
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
const flag = args[flagIdx];
|
|
714
|
-
if (flag !== '-lc' && flag !== '-c') return null;
|
|
718
|
+
const flagIdx = args.findIndex((arg, i) => i > pIdx && (arg === '-lc' || arg === '-c'));
|
|
719
|
+
if (flagIdx === -1) return null;
|
|
715
720
|
// The query-response form appends `|| <plain invocation>`; stop at that
|
|
716
721
|
// separator so the fallback branch is not folded into the recovered command.
|
|
717
722
|
const end = args.indexOf('||', flagIdx + 1);
|
|
@@ -807,37 +812,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
807
812
|
});
|
|
808
813
|
}
|
|
809
814
|
|
|
810
|
-
function
|
|
815
|
+
function buildSandboxSummary(config: SandboxConfig): string {
|
|
811
816
|
const { globalPath, projectPath } = getConfigPaths(directory);
|
|
812
|
-
const
|
|
813
|
-
const
|
|
814
|
-
|
|
815
|
-
const denyRead = list(config.filesystem.denyRead);
|
|
816
|
-
const allowRead = list(config.filesystem.allowRead);
|
|
817
|
-
const allowWrite = list(config.filesystem.allowWrite);
|
|
818
|
-
const denyWrite = list(config.filesystem.denyWrite);
|
|
819
|
-
|
|
820
|
-
return [
|
|
821
|
-
'# Sandbox Configuration',
|
|
822
|
-
'',
|
|
823
|
-
`Status: ${sandboxDisabled ? 'disabled for this session' : 'active'}`,
|
|
824
|
-
`landstrip package binary: ${landstripBinaryPath()}`,
|
|
825
|
-
'',
|
|
826
|
-
'Config files:',
|
|
827
|
-
`- project: ${projectPath}`,
|
|
828
|
-
`- global: ${globalPath}`,
|
|
829
|
-
'',
|
|
830
|
-
`Network (${networkMode}):`,
|
|
831
|
-
`- allow network: ${config.network.allowNetwork ? 'yes' : 'no'}`,
|
|
832
|
-
`- allowed: ${allowed}`,
|
|
833
|
-
`- denied: ${denied}`,
|
|
834
|
-
'',
|
|
835
|
-
'Filesystem:',
|
|
836
|
-
`- deny read: ${denyRead}`,
|
|
837
|
-
`- allow read: ${allowRead}`,
|
|
838
|
-
`- allow write: ${allowWrite}`,
|
|
839
|
-
`- deny write: ${denyWrite}`,
|
|
840
|
-
].join('\n');
|
|
817
|
+
const statusText = sandboxDisabled ? 'disabled for this session' : undefined;
|
|
818
|
+
const report = sandboxSummary(config, globalPath, projectPath, statusText);
|
|
819
|
+
return ['# Sandbox Configuration', '', report].join('\n');
|
|
841
820
|
}
|
|
842
821
|
|
|
843
822
|
client.app
|
|
@@ -1275,7 +1254,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1275
1254
|
'command.execute.before': async (input, output) => {
|
|
1276
1255
|
if (input.command.trim() === '/sandbox') {
|
|
1277
1256
|
const config = loadConfig(directory, optionOverrides);
|
|
1278
|
-
pushCommandText(input, output,
|
|
1257
|
+
pushCommandText(input, output, buildSandboxSummary(config));
|
|
1279
1258
|
await client.tui
|
|
1280
1259
|
?.showToast?.({
|
|
1281
1260
|
body: { title: 'Sandbox', message: `Config loaded for ${directory}`, variant: 'info' },
|
package/package.json
CHANGED
package/shared.ts
CHANGED
|
@@ -209,31 +209,43 @@ export function writeConfigFile(configPath: string, update: SandboxConfigOverrid
|
|
|
209
209
|
writeFileSync(configPath, JSON.stringify(next, null, 2) + '\n');
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
let _landstripBinaryPath: string | undefined;
|
|
213
|
+
let _landstripBinaryPathError: unknown;
|
|
214
|
+
|
|
212
215
|
export function landstripBinaryPath(): string {
|
|
213
|
-
|
|
214
|
-
|
|
216
|
+
if (_landstripBinaryPath !== undefined) return _landstripBinaryPath;
|
|
217
|
+
if (_landstripBinaryPathError !== undefined) throw _landstripBinaryPathError;
|
|
215
218
|
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
219
|
+
try {
|
|
220
|
+
const filePath = realpathSync.native(binaryPath());
|
|
221
|
+
let probe = dirname(filePath);
|
|
222
|
+
|
|
223
|
+
while (true) {
|
|
224
|
+
const manifestPath = join(probe, 'package.json');
|
|
225
|
+
if (existsSync(manifestPath)) {
|
|
226
|
+
try {
|
|
227
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as unknown;
|
|
228
|
+
if (isRecord(manifest) && LANDSTRIP_PACKAGE_NAMES.has(String(manifest.name))) {
|
|
229
|
+
_landstripBinaryPath = filePath;
|
|
230
|
+
return filePath;
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
// malformed package.json — continue walking to parent
|
|
223
234
|
}
|
|
224
|
-
} catch {
|
|
225
|
-
// malformed package.json — continue walking to parent
|
|
226
235
|
}
|
|
236
|
+
|
|
237
|
+
const parent = dirname(probe);
|
|
238
|
+
if (parent === probe) break;
|
|
239
|
+
probe = parent;
|
|
227
240
|
}
|
|
228
241
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
242
|
+
throw new Error(
|
|
243
|
+
`Refusing to use landstrip binary outside official @landstrip/landstrip packages: ${filePath}`,
|
|
244
|
+
);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
_landstripBinaryPathError = error;
|
|
247
|
+
throw error;
|
|
232
248
|
}
|
|
233
|
-
|
|
234
|
-
throw new Error(
|
|
235
|
-
`Refusing to use landstrip binary outside official @landstrip/landstrip packages: ${filePath}`,
|
|
236
|
-
);
|
|
237
249
|
}
|
|
238
250
|
|
|
239
251
|
export function extractDomainsFromCommand(command: string): string[] {
|
|
@@ -529,3 +541,48 @@ export function readDiscoveryPort(baseDirectory: string): number | null {
|
|
|
529
541
|
return null;
|
|
530
542
|
}
|
|
531
543
|
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Human-readable sandbox configuration report consumed by both the server
|
|
547
|
+
* command and the TUI inspector dialog.
|
|
548
|
+
*/
|
|
549
|
+
export function sandboxSummary(
|
|
550
|
+
config: SandboxConfig,
|
|
551
|
+
globalPath: string,
|
|
552
|
+
projectPath: string,
|
|
553
|
+
statusOverride?: string,
|
|
554
|
+
): string {
|
|
555
|
+
const networkMode = config.network.allowNetwork ? 'unrestricted' : 'proxied';
|
|
556
|
+
const allowed = list(config.network.allowedDomains);
|
|
557
|
+
const denied = list(config.network.deniedDomains);
|
|
558
|
+
const unixSockets = config.network.allowAllUnixSockets
|
|
559
|
+
? 'all'
|
|
560
|
+
: list(config.network.allowUnixSockets);
|
|
561
|
+
const denyRead = list(config.filesystem.denyRead);
|
|
562
|
+
const allowRead = list(config.filesystem.allowRead);
|
|
563
|
+
const allowWrite = list(config.filesystem.allowWrite);
|
|
564
|
+
const denyWrite = list(config.filesystem.denyWrite);
|
|
565
|
+
|
|
566
|
+
const status = statusOverride ?? (config.enabled ? 'active' : 'disabled by config');
|
|
567
|
+
|
|
568
|
+
return [
|
|
569
|
+
`Status: ${status}`,
|
|
570
|
+
`landstrip package binary: ${landstripBinaryPath()}`,
|
|
571
|
+
'',
|
|
572
|
+
'Config files',
|
|
573
|
+
`${projectPath} ${existsSync(projectPath) ? '(found)' : '(missing)'}`,
|
|
574
|
+
`${globalPath} ${existsSync(globalPath) ? '(found)' : '(missing)'}`,
|
|
575
|
+
'',
|
|
576
|
+
`Network: ${networkMode}`,
|
|
577
|
+
`allow network: ${config.network.allowNetwork ? 'yes' : 'no'}`,
|
|
578
|
+
`allowed: ${allowed}`,
|
|
579
|
+
`denied: ${denied}`,
|
|
580
|
+
`unix sockets: ${unixSockets}`,
|
|
581
|
+
'',
|
|
582
|
+
'Filesystem',
|
|
583
|
+
`deny read: ${denyRead}`,
|
|
584
|
+
`allow read: ${allowRead}`,
|
|
585
|
+
`allow write: ${allowWrite}`,
|
|
586
|
+
`deny write: ${denyWrite}`,
|
|
587
|
+
].join('\n');
|
|
588
|
+
}
|
package/tui.ts
CHANGED
|
@@ -2,21 +2,17 @@
|
|
|
2
2
|
// Copyright (C) Jarkko Sakkinen 2026
|
|
3
3
|
|
|
4
4
|
import type { TuiPlugin, TuiSlotContext, TuiSlotPlugin } from '@opencode-ai/plugin/tui';
|
|
5
|
-
|
|
6
|
-
import { existsSync } from 'node:fs';
|
|
7
5
|
import { type AddressInfo, createServer, type Socket as NetSocket } from 'node:net';
|
|
8
6
|
|
|
9
7
|
import {
|
|
10
|
-
list,
|
|
11
|
-
type SandboxConfigOverrides,
|
|
12
8
|
getConfigPaths,
|
|
13
|
-
landstripBinaryPath,
|
|
14
9
|
loadConfig,
|
|
15
10
|
normalizeOptions,
|
|
16
11
|
parseLandstripTraps,
|
|
17
12
|
permissionLabel,
|
|
18
13
|
permissionResource,
|
|
19
14
|
removeDiscoveryFile,
|
|
15
|
+
sandboxSummary,
|
|
20
16
|
updateForPermission,
|
|
21
17
|
writeConfigFile,
|
|
22
18
|
writeDiscoveryPort,
|
|
@@ -58,39 +54,6 @@ interface PermissionEntry {
|
|
|
58
54
|
|
|
59
55
|
type QueueEntry = PermissionEntry | FsQueryEntry;
|
|
60
56
|
|
|
61
|
-
function configPathLine(label: string, filePath: string): string {
|
|
62
|
-
return `${label}: ${filePath} ${existsSync(filePath) ? '(found)' : '(missing)'}`;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function sandboxSummary(baseDirectory: string, optionOverrides: SandboxConfigOverrides): string {
|
|
66
|
-
const config = loadConfig(baseDirectory, optionOverrides);
|
|
67
|
-
const { globalPath, projectPath } = getConfigPaths(baseDirectory);
|
|
68
|
-
const networkMode = config.network.allowNetwork ? 'unrestricted' : 'proxied';
|
|
69
|
-
|
|
70
|
-
return [
|
|
71
|
-
`Status: ${config.enabled ? 'active' : 'disabled by config'}`,
|
|
72
|
-
`landstrip package binary: ${landstripBinaryPath()}`,
|
|
73
|
-
'',
|
|
74
|
-
'Config files',
|
|
75
|
-
configPathLine('project', projectPath),
|
|
76
|
-
configPathLine('global', globalPath),
|
|
77
|
-
'',
|
|
78
|
-
`Network: ${networkMode}`,
|
|
79
|
-
`allow network: ${config.network.allowNetwork ? 'yes' : 'no'}`,
|
|
80
|
-
`allowed: ${list(config.network.allowedDomains)}`,
|
|
81
|
-
`denied: ${list(config.network.deniedDomains)}`,
|
|
82
|
-
`unix sockets: ${config.network.allowAllUnixSockets ? 'all' : list(config.network.allowUnixSockets)}`,
|
|
83
|
-
'',
|
|
84
|
-
'Filesystem',
|
|
85
|
-
`deny read: ${list(config.filesystem.denyRead)}`,
|
|
86
|
-
`allow read: ${list(config.filesystem.allowRead)}`,
|
|
87
|
-
`allow write: ${list(config.filesystem.allowWrite)}`,
|
|
88
|
-
`deny write: ${list(config.filesystem.denyWrite)}`,
|
|
89
|
-
'',
|
|
90
|
-
'Press esc or enter to close',
|
|
91
|
-
].join('\n');
|
|
92
|
-
}
|
|
93
|
-
|
|
94
57
|
function asRecord(permission: PendingPermission): Record<string, unknown> {
|
|
95
58
|
return permission as unknown as Record<string, unknown>;
|
|
96
59
|
}
|
|
@@ -475,7 +438,10 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
475
438
|
|
|
476
439
|
const showSandbox = () => {
|
|
477
440
|
const directory = api.state.path.directory || process.cwd();
|
|
478
|
-
const
|
|
441
|
+
const config = loadConfig(directory, optionOverrides);
|
|
442
|
+
const { globalPath, projectPath } = getConfigPaths(directory);
|
|
443
|
+
const message =
|
|
444
|
+
sandboxSummary(config, globalPath, projectPath) + '\n\nPress esc or enter to close';
|
|
479
445
|
|
|
480
446
|
// No `onConfirm`/`onClose` that call `clear()`: the host already pops the
|
|
481
447
|
// dialog on enter/esc/click, and its `clear()` re-invokes every entry's
|
|
@@ -489,7 +455,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
489
455
|
};
|
|
490
456
|
|
|
491
457
|
const executeServerCommand = async (command: string): Promise<boolean> => {
|
|
492
|
-
await api.client.tui.executeCommand({ command });
|
|
458
|
+
await api.client.tui.executeCommand({ command: `/${command}` });
|
|
493
459
|
return true;
|
|
494
460
|
};
|
|
495
461
|
|
|
@@ -502,6 +468,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
502
468
|
desc: 'Show sandbox configuration',
|
|
503
469
|
category: 'Sandbox',
|
|
504
470
|
suggested: true,
|
|
471
|
+
slash: { name: 'sandbox' },
|
|
505
472
|
slashName: 'sandbox',
|
|
506
473
|
run: showSandbox,
|
|
507
474
|
},
|
|
@@ -512,6 +479,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
512
479
|
desc: 'Disable sandbox for this session',
|
|
513
480
|
category: 'Sandbox',
|
|
514
481
|
suggested: true,
|
|
482
|
+
slash: { name: 'sandbox-disable' },
|
|
515
483
|
slashName: 'sandbox-disable',
|
|
516
484
|
run: () => executeServerCommand('sandbox-disable'),
|
|
517
485
|
},
|
|
@@ -522,12 +490,43 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
522
490
|
desc: 'Re-enable sandbox for this session',
|
|
523
491
|
category: 'Sandbox',
|
|
524
492
|
suggested: true,
|
|
493
|
+
slash: { name: 'sandbox-enable' },
|
|
525
494
|
slashName: 'sandbox-enable',
|
|
526
495
|
run: () => executeServerCommand('sandbox-enable'),
|
|
527
496
|
},
|
|
528
497
|
],
|
|
529
498
|
});
|
|
530
499
|
|
|
500
|
+
api.command?.register(() => [
|
|
501
|
+
{
|
|
502
|
+
title: 'Sandbox',
|
|
503
|
+
value: 'sandbox',
|
|
504
|
+
description: 'Show sandbox configuration',
|
|
505
|
+
category: 'Sandbox',
|
|
506
|
+
suggested: true,
|
|
507
|
+
slash: { name: 'sandbox' },
|
|
508
|
+
onSelect: showSandbox,
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
title: 'Disable sandbox',
|
|
512
|
+
value: 'sandbox-disable',
|
|
513
|
+
description: 'Disable sandbox for this session',
|
|
514
|
+
category: 'Sandbox',
|
|
515
|
+
suggested: true,
|
|
516
|
+
slash: { name: 'sandbox-disable' },
|
|
517
|
+
onSelect: () => executeServerCommand('sandbox-disable'),
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
title: 'Enable sandbox',
|
|
521
|
+
value: 'sandbox-enable',
|
|
522
|
+
description: 'Re-enable sandbox for this session',
|
|
523
|
+
category: 'Sandbox',
|
|
524
|
+
suggested: true,
|
|
525
|
+
slash: { name: 'sandbox-enable' },
|
|
526
|
+
onSelect: () => executeServerCommand('sandbox-enable'),
|
|
527
|
+
},
|
|
528
|
+
]);
|
|
529
|
+
|
|
531
530
|
// Persistent status badge in the prompt area. It needs the host's Solid
|
|
532
531
|
// runtime, imported defensively so a host that resolves plugin imports
|
|
533
532
|
// differently still loads the plugin — the badge just stays absent there.
|