opencode-landstrip 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.ts +40 -41
  2. package/package.json +2 -2
  3. package/tui.ts +17 -12
package/index.ts CHANGED
@@ -33,11 +33,11 @@ interface LandstripPolicy {
33
33
  }
34
34
 
35
35
  type LandstripTrap =
36
- | { kind: 'Filesystem'; operation: 'read' | 'write'; path: string; mechanism: string }
37
- | { kind: 'Network'; operation: string; target: string; mechanism: string }
38
- | { kind: 'Launch'; program: string; message: string }
39
- | { kind: 'Usage'; message: string }
40
- | { kind: 'Internal'; fields: Record<string, string> };
36
+ | { kind: 'filesystem'; operation: 'read' | 'write'; path: string; mechanism: string }
37
+ | { kind: 'network'; operation: string; target: string; mechanism: string }
38
+ | { kind: 'launch'; program: string; message: string }
39
+ | { kind: 'usage'; message: string }
40
+ | { kind: 'internal'; detail: Record<string, string> };
41
41
 
42
42
  interface BashSandboxState {
43
43
  originalCommand: string;
@@ -58,7 +58,7 @@ interface SandboxPermissionDecision {
58
58
 
59
59
  type ToastVariant = 'info' | 'success' | 'warning' | 'error';
60
60
 
61
- const LANDSTRIP_VERSION = [0, 14, 5] as const;
61
+ const LANDSTRIP_VERSION = [0, 15, 1] 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']);
@@ -247,9 +247,9 @@ function extractBlockedPath(
247
247
  // Landstrip structured trap format carrying a denied path
248
248
  const landstripErrors = parseLandstripErrors(output);
249
249
  for (const trap of landstripErrors) {
250
- if (trap.kind === 'Filesystem') return normalizeBlockedPath(trap.path, baseDirectory);
251
- if (trap.kind === 'Internal' && trap.fields.file) {
252
- return normalizeBlockedPath(trap.fields.file, baseDirectory);
250
+ if (trap.kind === 'filesystem') return normalizeBlockedPath(trap.path, baseDirectory);
251
+ if (trap.kind === 'internal' && trap.detail.file) {
252
+ return normalizeBlockedPath(trap.detail.file, baseDirectory);
253
253
  }
254
254
  }
255
255
 
@@ -407,40 +407,39 @@ function hasMinimumVersion(version: string, minimum: readonly [number, number, n
407
407
 
408
408
  function decodeLandstripTrap(value: unknown): LandstripTrap | null {
409
409
  if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
410
- const entries = Object.entries(value as Record<string, unknown>);
411
- if (entries.length !== 1) return null;
412
- const [kind, payload] = entries[0];
413
-
414
- switch (kind) {
415
- case 'Filesystem': {
416
- if (!Array.isArray(payload)) return null;
417
- const [operation, path, mechanism] = payload;
410
+ const record = value as Record<string, unknown>;
411
+ const mechanism = typeof record.mechanism === 'string' ? record.mechanism : '';
412
+
413
+ switch (record.kind) {
414
+ case 'filesystem': {
415
+ const { operation, path } = record;
418
416
  if (!isLandstripOperation(operation) || typeof path !== 'string') return null;
419
- return { kind, operation, path, mechanism: typeof mechanism === 'string' ? mechanism : '' };
417
+ return { kind: 'filesystem', operation, path, mechanism };
420
418
  }
421
- case 'Network': {
422
- if (!Array.isArray(payload)) return null;
423
- const [operation, target, mechanism] = payload;
419
+ case 'network': {
420
+ const { operation, target } = record;
424
421
  if (typeof operation !== 'string' || typeof target !== 'string') return null;
425
- return { kind, operation, target, mechanism: typeof mechanism === 'string' ? mechanism : '' };
422
+ return { kind: 'network', operation, target, mechanism };
426
423
  }
427
- case 'Launch': {
428
- if (!Array.isArray(payload)) return null;
429
- const [program, message] = payload;
424
+ case 'launch': {
425
+ const { program, message } = record;
430
426
  if (typeof program !== 'string') return null;
431
- return { kind, program, message: typeof message === 'string' ? message : '' };
427
+ return { kind: 'launch', program, message: typeof message === 'string' ? message : '' };
432
428
  }
433
- case 'Usage': {
434
- if (typeof payload !== 'string') return null;
435
- return { kind, message: payload };
429
+ case 'usage': {
430
+ const { message } = record;
431
+ if (typeof message !== 'string') return null;
432
+ return { kind: 'usage', message };
436
433
  }
437
- case 'Internal': {
438
- if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return null;
439
- const fields: Record<string, string> = {};
440
- for (const [key, val] of Object.entries(payload as Record<string, unknown>)) {
441
- fields[key] = typeof val === 'string' ? val : JSON.stringify(val);
434
+ case 'internal': {
435
+ const detail: Record<string, string> = {};
436
+ const payload = record.detail;
437
+ if (typeof payload === 'object' && payload !== null && !Array.isArray(payload)) {
438
+ for (const [key, val] of Object.entries(payload as Record<string, unknown>)) {
439
+ detail[key] = typeof val === 'string' ? val : JSON.stringify(val);
440
+ }
442
441
  }
443
- return { kind, fields };
442
+ return { kind: 'internal', detail };
444
443
  }
445
444
  default:
446
445
  return null;
@@ -470,20 +469,20 @@ function parseLandstripErrors(output: string): LandstripTrap[] {
470
469
 
471
470
  function formatLandstripTrap(trap: LandstripTrap): string {
472
471
  switch (trap.kind) {
473
- case 'Filesystem':
472
+ case 'filesystem':
474
473
  return `landstrip: filesystem ${trap.operation} denied (${trap.path})${
475
474
  trap.mechanism ? ` [${trap.mechanism}]` : ''
476
475
  }`;
477
- case 'Network':
476
+ case 'network':
478
477
  return `landstrip: network ${trap.operation} denied (${trap.target})${
479
478
  trap.mechanism ? ` [${trap.mechanism}]` : ''
480
479
  }`;
481
- case 'Launch':
480
+ case 'launch':
482
481
  return `landstrip: launch failed (${trap.program})${trap.message ? `: ${trap.message}` : ''}`;
483
- case 'Usage':
482
+ case 'usage':
484
483
  return `landstrip: usage error: ${trap.message}`;
485
- case 'Internal': {
486
- const detail = Object.entries(trap.fields)
484
+ case 'internal': {
485
+ const detail = Object.entries(trap.detail)
487
486
  .map(([key, val]) => `${key}: ${val}`)
488
487
  .join(', ');
489
488
  return `landstrip: internal error${detail ? ` (${detail})` : ''}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Landlock-based sandboxing for opencode with landstrip",
5
5
  "keywords": [
6
6
  "landlock",
@@ -49,7 +49,7 @@
49
49
  "ci:test": "npm test"
50
50
  },
51
51
  "dependencies": {
52
- "@landstrip/landstrip": "^0.14.5"
52
+ "@landstrip/landstrip": "^0.15.1"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@opencode-ai/plugin": "^1.17.7",
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 any key to close',
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
- pump();
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
- api.ui.dialog.clear();
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
- api.ui.dialog.replace(
237
- () =>
238
- api.ui.DialogAlert({
239
- title: 'Sandbox Configuration',
240
- message,
241
- onConfirm: () => api.ui.dialog.clear(),
242
- }),
243
- () => api.ui.dialog.clear(),
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