pi-landstrip 0.19.13 → 0.19.14

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 (4) hide show
  1. package/README.md +51 -71
  2. package/api.ts +134 -11
  3. package/dist/index.ts +45 -19
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -9,52 +9,38 @@ Requires Pi ≥0.82.0 and Node.js ≥22.19.0.
9
9
  pi install npm:pi-landstrip
10
10
  ```
11
11
 
12
- Includes native binaries for Linux, macOS, and Windows on x64 and Arm64.
13
- Enabled sandboxing fails closed if the binary or platform is unusable;
14
- `--no-sandbox` or `enabled: false` explicitly permits unsandboxed execution.
12
+ Unusable sandboxing fails closed; `--no-sandbox` or `enabled: false` permits
13
+ unsandboxed execution.
15
14
 
16
15
  ## Permissions and configuration
17
16
 
18
- Agent permissions govern tool dispatch, not sandbox access: approval never
19
- bypasses a sandbox hard denial. With sandboxing enabled, AI Bash, `!`, `!!`, and
20
- subagents use OS isolation. Primary file tools run in trusted Pi; setting
21
- `toolFilesystemPolicy: "sandbox"` applies filesystem policy checks, not OS isolation.
17
+ With sandboxing enabled, Bash (`bash`, `!`, `!!`) and workers are OS-isolated.
18
+ Primary file tools remain in trusted Pi; `toolFilesystemPolicy: "sandbox"`
19
+ adds policy checks, not isolation. Tool approval never bypasses hard denials.
22
20
 
23
- Policy merges from bundled [`sandbox.json`](sandbox.json), then
24
- `~/.pi/agent/sandbox.json`, then trusted-project `.pi/sandbox.json`.
25
- Arrays combine; later scalar values win. Persistent sandbox approvals update
26
- the global or project file. Store only overrides:
21
+ Configuration: bundled [`sandbox.json`](sandbox.json) → `~/.pi/agent/sandbox.json`
22
+ → trusted-project `.pi/sandbox.json`. Arrays combine; later scalars win.
23
+ Persistent approvals update global/project overrides. For policy-only shell reads:
27
24
 
28
25
  ```json
29
- {
30
- "shell": { "readAccess": "policy" },
31
- "filesystem": { "allowWrite": ["."] },
32
- "network": { "allowNetwork": false }
33
- }
26
+ { "shell": { "readAccess": "policy" } }
34
27
  ```
35
28
 
36
- The bundled shell default is `readAccess: "host"`; workers always use policy
37
- reads. Windows requires an explicit read allowlist. Native policy and CLI
38
- semantics are in [landstrip(1)](../landstrip/man/man1/landstrip.1).
39
- Runtime seccomp traps and query approval are Linux-only. On macOS, `--trap`
40
- reports setup/launch errors only and closes on successful exec.
41
-
42
- Worker read boundaries:
43
-
44
- - Bootstrap grants can override bundled `denyRead` defaults, never persisted
45
- global or trusted-project denies. Denied startup resources prevent launch.
46
- Legacy copies of `/home` or `/Users` count as explicit denies: remove them only
47
- if unintended; bundled defaults remain. New global files start as `{}`.
48
- - `filesystem.denyReadAlways` cannot be overridden by equal/nested read grants
49
- or shell host reads. Private workers protect parent auth-file and lock roots
50
- with this policy and `denyWrite`. Non-empty `denyReadAlways` and private worker
51
- auth are unsupported on Windows.
52
- - `filesystem.denyWriteAlways` cannot be overridden by equal/nested write grants
53
- or interactive prompts; write attempts are silently rejected. Non-empty
54
- `denyWriteAlways` is unsupported on Windows.
55
- - External skill, prompt, and theme directories may need explicit discovery
56
- access. Prefer resource file paths: resolved-file grants do not cover enclosing
57
- directories.
29
+ Shell reads default to `"host"`; workers always use policy reads. See
30
+ [landstrip(1)](../landstrip/man/man1/landstrip.1) for native policy, traps,
31
+ and platform limits, including Windows read grants and hard-denial restrictions.
32
+
33
+ Worker boundaries:
34
+
35
+ - Bootstrap grants override bundled `denyRead`, never global/trusted-project
36
+ denies. Denied startup resources prevent launch. Legacy `/home` or `/Users`
37
+ denies are explicit overrides; remove them only if unintended.
38
+ - Hard read denials also apply to shell host reads. Private workers protect
39
+ parent auth-file/lock roots with `denyReadAlways` and `denyWrite`;
40
+ private worker authentication is unsupported on Windows.
41
+ - `filesystem.denyWriteAlways` rejects write attempts without prompting.
42
+ - External skills/prompts/themes may need directory discovery grants;
43
+ resolved-file grants do not include parent directories.
58
44
 
59
45
  ## Agents and tasks
60
46
 
@@ -69,49 +55,43 @@ Configure `~/.pi/agent/landstrip.json`, trusted-project `.pi/landstrip.json`, or
69
55
  }
70
56
  ```
71
57
 
72
- `maxSubagents` accepts 0–16. Agent definitions can override model, prompt, mode,
73
- options, and permissions.
58
+ `maxSubagents`: 0–16. Agent definitions can override model, prompt, mode, options,
59
+ and permissions.
74
60
 
75
- `/landstrip` opens the management pane; `/landstrip help` lists commands and
76
- shortcuts. Common commands:
61
+ Use `/landstrip` for the management pane and `/landstrip help` for commands.
62
+ Task IDs accept unique prefixes; bare IDs open logs. `/landstrip tasks kill <id>`
63
+ terminates a task. `Ctrl+Shift+A` cycles primary agents while idle.
64
+ The `task` tool accepts `background: true` for immediate return and `task_id`
65
+ to continue a saved task. Press `Enter` in running/queued logs to steer workers.
77
66
 
78
- | Command | Action |
79
- | --------------------------------- | ------------------------------- |
80
- | `/landstrip status` | Inspect sandbox, agents, tasks. |
81
- | `/landstrip settings` | Set concurrency/file policy. |
82
- | `/landstrip sandbox [on\|off]` | Inspect/toggle OS isolation. |
83
- | `/landstrip agents [@name]` | List/select primary agents. |
84
- | `/landstrip subagents` | List process subagents. |
85
- | `/landstrip tasks [list]` | List task sessions. |
86
- | `/landstrip tasks kill <task-id>` | Terminate a task. |
87
- | `/landstrip logs [task-id]` | Open task logs. |
67
+ ### Worker environment and authentication
88
68
 
89
- Task IDs accept unique prefixes; bare IDs open logs in the pane.
90
- `Ctrl+Shift+A` cycles primary agents while Pi is idle. The `task` tool accepts
91
- `background: true` for immediate return and `task_id` to continue a saved task.
92
- Press `Enter` in running/queued task logs to steer the worker.
69
+ Workers start `--offline`; install resources and refresh model catalogs in the
70
+ parent first. Provider endpoints still need network permission.
93
71
 
94
- ### Worker environment and authentication
72
+ Only runtime paths, locale, certificate paths, and supported Pi config are
73
+ inherited—not parent API keys, tool tokens, proxy URLs, or arbitrary variables.
74
+ The parent refreshes selected-provider auth and sends it, along with selected
75
+ Bedrock/Vertex/Azure settings, over a private Unix pipe, not arguments or
76
+ environment. Configure tool logins separately; authenticated workers cannot
77
+ launch on Windows.
95
78
 
96
- Workers start `--offline`: install resources and refresh model catalogs in the
97
- parent first. Model/auth requests still need permitted endpoints.
79
+ Ambient AWS profiles need an explicit region for non-ARN Bedrock models.
80
+ Custom credential-chain environment paths are unsupported; use scoped keys or
81
+ a Bedrock bearer token. Provider credential files need filesystem grants.
98
82
 
99
- Workers inherit only runtime paths, locale, certificate paths, and supported Pi
100
- configuration—not parent API keys, tool tokens, proxy URLs, or arbitrary variables.
101
- The parent refreshes selected-provider auth and sends it through a private Unix
102
- pipe, never arguments or worker environment. Environment-based tool logins need
103
- separate configuration; authenticated workers fail before spawning on Windows.
83
+ ## Extension API
104
84
 
105
- Selected Bedrock, Vertex, and Azure settings also travel privately. Ambient AWS
106
- profiles need an explicit region for non-ARN models; custom credential-chain
107
- environment paths are unsupported (use scoped keys or a Bedrock bearer token).
108
- Provider credential files still need filesystem permission.
85
+ [`pi-landstrip/api`](api.ts) exports `createLandstrip`. `prepareProcess()` returns
86
+ `undefined` without a runtime. Dispose prepared processes separately from the
87
+ discovery handle; generic `spawn()` returns `ChildProcess`, RPC workers use
88
+ piped streams.
109
89
 
110
90
  ## Development
111
91
 
112
- From this package: `npm ci`, then `npm run all` (format, lint, typecheck, build,
113
- and tests; building requires Bun). For a read-only formatting check: `npm run ci:fmt`.
92
+ From this package: `npm ci && npm run all` (Bun required).
93
+ `npm run ci:fmt` checks formatting without edits.
114
94
 
115
95
  ## License
116
96
 
117
- Apache-2.0; see [LICENSE](LICENSE).
97
+ [Apache-2.0](LICENSE).
package/api.ts CHANGED
@@ -105,6 +105,11 @@ export interface LandstripPreparedProcess {
105
105
  dispose(): Promise<void>;
106
106
  }
107
107
 
108
+ export interface Landstrip {
109
+ prepareProcess(options: LandstripProcessOptions): Promise<LandstripPreparedProcess | undefined>;
110
+ dispose(): void;
111
+ }
112
+
108
113
  export interface LandstripRpcChildProcess {
109
114
  readonly stdin: Writable;
110
115
  readonly stdout: Readable;
@@ -126,7 +131,7 @@ export type LandstripSpawn = (
126
131
  command: string,
127
132
  args: readonly string[],
128
133
  options: SpawnOptions,
129
- ) => ChildProcess | LandstripRpcChildProcess;
134
+ ) => ChildProcess;
130
135
 
131
136
  export type LandstripEvent =
132
137
  | { readonly type: 'sandbox.changed'; readonly context: LandstripContextV2 }
@@ -184,35 +189,152 @@ function isRuntime(value: unknown): value is PiLandstripRuntimeV2 {
184
189
  );
185
190
  }
186
191
 
187
- export function useLandstrip(
192
+ function isDisposableProcess(value: unknown): value is Pick<LandstripPreparedProcess, 'dispose'> {
193
+ return isRecord(value) && typeof value.dispose === 'function';
194
+ }
195
+
196
+ function isPreparedProcess(value: unknown): value is LandstripPreparedProcess {
197
+ return (
198
+ isRecord(value) &&
199
+ typeof value.command === 'string' &&
200
+ value.command.trim().length > 0 &&
201
+ Array.isArray(value.args) &&
202
+ [...value.args].every((arg: unknown) => typeof arg === 'string') &&
203
+ typeof value.cwd === 'string' &&
204
+ value.cwd.length > 0 &&
205
+ isRecord(value.env) &&
206
+ Object.values(value.env).every((entry) => entry === undefined || typeof entry === 'string') &&
207
+ typeof value.spawn === 'function' &&
208
+ isDisposableProcess(value)
209
+ );
210
+ }
211
+
212
+ function discoverLandstrip(
188
213
  pi: ExtensionAPI,
189
214
  callback: (runtime: PiLandstripRuntimeV2) => void,
215
+ onInvalid?: (error: Error) => void,
190
216
  ): () => void {
191
217
  const events = eventBus(pi);
192
218
  if (!events) return () => undefined;
193
219
 
194
220
  let current: PiLandstripRuntimeV2 | undefined;
195
- const register = (runtime: PiLandstripRuntimeV2): void => {
196
- if (runtime === current) return;
197
- current = runtime;
198
- callback(runtime);
221
+ let disposed = false;
222
+ const register = (value: unknown): void => {
223
+ if (disposed) return;
224
+ if (!isRuntime(value)) {
225
+ onInvalid?.(new Error('Invalid Landstrip runtime v2 declaration'));
226
+ return;
227
+ }
228
+ if (value === current) return;
229
+ current = value;
230
+ callback(value);
199
231
  };
200
232
  const unsubscribe = events.on(RUNTIME_REGISTER_EVENT, (value) => {
201
- if (!isRecord(value) || value.version !== LANDSTRIP_RUNTIME_VERSION) return;
202
- if (isRuntime(value.runtime)) register(value.runtime);
233
+ if (disposed) return;
234
+ if (!isRecord(value) || value.version !== LANDSTRIP_RUNTIME_VERSION) {
235
+ onInvalid?.(new Error('Invalid Landstrip runtime v2 registration'));
236
+ return;
237
+ }
238
+ register(value.runtime);
203
239
  });
240
+ const dispose = (): void => {
241
+ if (disposed) return;
242
+ disposed = true;
243
+ unsubscribe();
244
+ };
204
245
  try {
205
246
  events.emit(RUNTIME_DISCOVER_EVENT, {
206
247
  version: LANDSTRIP_RUNTIME_VERSION,
207
248
  register,
208
249
  } satisfies RuntimeDiscovery);
209
- return unsubscribe;
250
+ return dispose;
210
251
  } catch (error) {
211
- unsubscribe();
252
+ dispose();
212
253
  throw error;
213
254
  }
214
255
  }
215
256
 
257
+ export function useLandstrip(
258
+ pi: ExtensionAPI,
259
+ callback: (runtime: PiLandstripRuntimeV2) => void,
260
+ ): () => void {
261
+ return discoverLandstrip(pi, callback);
262
+ }
263
+
264
+ export function createLandstrip(pi: ExtensionAPI): Landstrip {
265
+ let runtime: PiLandstripRuntimeV2 | undefined;
266
+ let runtimeError: Error | undefined;
267
+ let disposed = false;
268
+ const stopDiscovery = discoverLandstrip(
269
+ pi,
270
+ (value) => {
271
+ runtime = value;
272
+ },
273
+ (error) => {
274
+ runtimeError ??= error;
275
+ },
276
+ );
277
+ if (runtimeError) {
278
+ stopDiscovery();
279
+ throw runtimeError;
280
+ }
281
+
282
+ return {
283
+ async prepareProcess(options) {
284
+ if (disposed) throw new Error('Landstrip handle has been disposed');
285
+ if (runtimeError) throw runtimeError;
286
+ options.signal?.throwIfAborted();
287
+ if (!runtime) return undefined;
288
+
289
+ const prepared: unknown = await runtime.prepareProcess(options);
290
+ if (!isDisposableProcess(prepared)) {
291
+ throw new Error('Landstrip returned a process without a disposal method');
292
+ }
293
+ let disposePromise: Promise<void> | undefined;
294
+ const dispose = (): Promise<void> => {
295
+ disposePromise ??= Promise.resolve().then(() => prepared.dispose());
296
+ return disposePromise;
297
+ };
298
+ try {
299
+ options.signal?.throwIfAborted();
300
+ if (disposed) throw new Error('Landstrip handle has been disposed');
301
+ if (runtimeError) throw runtimeError;
302
+ if (!isPreparedProcess(prepared)) {
303
+ throw new Error('Landstrip returned an invalid prepared process');
304
+ }
305
+ } catch (error) {
306
+ try {
307
+ await dispose();
308
+ } catch (cleanupError) {
309
+ throw new AggregateError(
310
+ [error, cleanupError],
311
+ 'Landstrip process preparation cleanup failed',
312
+ );
313
+ }
314
+ throw error;
315
+ }
316
+
317
+ return {
318
+ command: prepared.command,
319
+ args: prepared.args,
320
+ cwd: prepared.cwd,
321
+ env: prepared.env,
322
+ spawn(command, args, spawnOptions) {
323
+ if (disposePromise) throw new Error('Prepared Landstrip process has been disposed');
324
+ return prepared.spawn(command, args, spawnOptions);
325
+ },
326
+ dispose,
327
+ };
328
+ },
329
+ dispose() {
330
+ if (disposed) return;
331
+ disposed = true;
332
+ stopDiscovery();
333
+ runtime = undefined;
334
+ },
335
+ };
336
+ }
337
+
216
338
  function provideExtensionProvider(
217
339
  pi: ExtensionAPI,
218
340
  register: (runtime: PiLandstripRuntimeV2) => () => void,
@@ -283,7 +405,8 @@ export function contextFromEnvironment(
283
405
  value.version !== LANDSTRIP_RUNTIME_VERSION ||
284
406
  value.host !== 'pi' ||
285
407
  value.role !== 'subagent' ||
286
- !['enabled', 'disabled', 'unavailable'].includes(String(value.sandbox)) ||
408
+ typeof value.sandbox !== 'string' ||
409
+ !['enabled', 'disabled', 'unavailable'].includes(value.sandbox) ||
287
410
  typeof value.cwd !== 'string' ||
288
411
  !Number.isInteger(value.depth) ||
289
412
  (value.depth as number) < 0 ||
package/dist/index.ts CHANGED
@@ -6029,7 +6029,7 @@ function createLandstripIntegrationWithPrompts(options, permissionPrompts) {
6029
6029
  if (!provider)
6030
6030
  return { decision: "abstain" };
6031
6031
  const result = await provider.decide(request);
6032
- if (typeof result !== "object" || result === null || !("decision" in result) || !["allow", "deny", "abstain"].includes(String(result.decision)) || "reason" in result && result.reason !== undefined && typeof result.reason !== "string") {
6032
+ if (typeof result !== "object" || result === null || !("decision" in result) || typeof result.decision !== "string" || !["allow", "deny", "abstain"].includes(result.decision) || "reason" in result && result.reason !== undefined && typeof result.reason !== "string") {
6033
6033
  throw new Error(`Permission ask provider "${provider.id}" returned an invalid decision`);
6034
6034
  }
6035
6035
  return result;
@@ -6390,7 +6390,7 @@ ${paths}${note}`, PERMISSION_OPTIONS, promptSignal);
6390
6390
  }
6391
6391
  });
6392
6392
  }
6393
- function prepareUnsandboxedRpcWorker(options) {
6393
+ function prepareUnsandboxedProcess(options) {
6394
6394
  let spawned = false;
6395
6395
  let disposed = false;
6396
6396
  let disposePromise;
@@ -6426,7 +6426,7 @@ ${paths}${note}`, PERMISSION_OPTIONS, promptSignal);
6426
6426
  }
6427
6427
  };
6428
6428
  }
6429
- async function prepareRpcWorker(options) {
6429
+ async function prepareManagedProcess(options) {
6430
6430
  if (options.signal?.aborted)
6431
6431
  throw new Error("Task cancelled");
6432
6432
  if (!ensureSandboxState(options.ctx)) {
@@ -6437,7 +6437,7 @@ ${paths}${note}`, PERMISSION_OPTIONS, promptSignal);
6437
6437
  warnUnsandboxed(options.ctx, "Subagent processes are running without Landstrip sandboxing", "warning");
6438
6438
  unsandboxedWorkerWarningShown = true;
6439
6439
  }
6440
- return prepareUnsandboxedRpcWorker(options);
6440
+ return prepareUnsandboxedProcess(options);
6441
6441
  }
6442
6442
  const allowances = {
6443
6443
  permissionPromptLabel: options.permissionPromptLabel,
@@ -6475,7 +6475,28 @@ ${paths}${note}`, PERMISSION_OPTIONS, promptSignal);
6475
6475
  let policy;
6476
6476
  let trapSocket;
6477
6477
  let childEnd;
6478
+ let preparedChild;
6478
6479
  let disposed = false;
6480
+ const cleanup = async () => {
6481
+ const errors = [];
6482
+ for (const release of [
6483
+ () => stopPreparedChild(preparedChild),
6484
+ () => trapSocket?.destroy(),
6485
+ () => childEnd?.destroy(),
6486
+ () => {
6487
+ if (policy)
6488
+ rmSync2(policy.dir, { recursive: true, force: true });
6489
+ },
6490
+ () => proxy?.stop()
6491
+ ]) {
6492
+ try {
6493
+ await release();
6494
+ } catch (error) {
6495
+ errors.push(error);
6496
+ }
6497
+ }
6498
+ return errors;
6499
+ };
6479
6500
  try {
6480
6501
  if (options.signal?.aborted)
6481
6502
  throw new Error("Task cancelled");
@@ -6487,20 +6508,17 @@ ${paths}${note}`, PERMISSION_OPTIONS, promptSignal);
6487
6508
  attachWorkerTrap(trapSocket, options.ctx, options.cwd, processAllowances, options.signal);
6488
6509
  }
6489
6510
  } catch (error) {
6490
- trapSocket?.destroy();
6491
- childEnd?.destroy();
6492
- if (policy)
6493
- rmSync2(policy.dir, { recursive: true, force: true });
6494
- await proxy?.stop();
6511
+ const cleanupErrors = await cleanup();
6512
+ if (cleanupErrors.length > 0) {
6513
+ throw new AggregateError([error, ...cleanupErrors], "Landstrip process preparation cleanup failed");
6514
+ }
6495
6515
  throw error;
6496
6516
  }
6497
6517
  const workerPolicy = policy;
6498
- const workerTrapSocket = trapSocket;
6499
6518
  const workerChildEnd = childEnd;
6500
6519
  const workerEnv = createProxyEnvironment(proxy?.port ?? null, proxyToken, options.env ?? {});
6501
6520
  let spawned = false;
6502
6521
  let disposePromise;
6503
- let preparedChild;
6504
6522
  const spawnWorker = (_command, _args, spawnOptions) => {
6505
6523
  if (disposed)
6506
6524
  throw new Error("Prepared process has been disposed");
@@ -6535,9 +6553,9 @@ ${paths}${note}`, PERMISSION_OPTIONS, promptSignal);
6535
6553
  detached: true,
6536
6554
  stdio
6537
6555
  });
6538
- workerChildEnd?.destroy();
6539
- patchProcessGroupKill(child);
6540
6556
  preparedChild = child;
6557
+ patchProcessGroupKill(child);
6558
+ workerChildEnd?.destroy();
6541
6559
  return child;
6542
6560
  };
6543
6561
  return {
@@ -6549,21 +6567,29 @@ ${paths}${note}`, PERMISSION_OPTIONS, promptSignal);
6549
6567
  dispose() {
6550
6568
  disposePromise ??= (async () => {
6551
6569
  disposed = true;
6552
- await stopPreparedChild(preparedChild);
6553
- workerTrapSocket?.destroy();
6554
- workerChildEnd?.destroy();
6555
- rmSync2(workerPolicy.dir, { recursive: true, force: true });
6556
- await proxy?.stop();
6570
+ const cleanupErrors = await cleanup();
6571
+ if (cleanupErrors.length > 0) {
6572
+ throw new AggregateError(cleanupErrors, "Landstrip process disposal cleanup failed");
6573
+ }
6557
6574
  })();
6558
6575
  return disposePromise;
6559
6576
  }
6560
6577
  };
6561
6578
  }
6579
+ async function prepareRpcWorker(options) {
6580
+ const prepared = await prepareManagedProcess(options);
6581
+ return {
6582
+ ...prepared,
6583
+ spawn(command, args, spawnOptions) {
6584
+ return prepared.spawn(command, args, spawnOptions);
6585
+ }
6586
+ };
6587
+ }
6562
6588
  async function prepareProcess(options) {
6563
6589
  if (process.platform === "win32" && (getNoSandboxFlag() || !loadConfig(options.cwd).enabled)) {
6564
6590
  throw new Error("Generic process preparation requires sandboxing on Windows");
6565
6591
  }
6566
- return prepareRpcWorker({
6592
+ return prepareManagedProcess({
6567
6593
  ...options,
6568
6594
  env: options.env ?? process.env,
6569
6595
  readPaths: options.readPaths ?? [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.19.13",
3
+ "version": "0.19.14",
4
4
  "description": "Sandboxed Bash and process-backed agents for Pi",
5
5
  "keywords": [
6
6
  "landstrip",
@@ -39,7 +39,7 @@
39
39
  "ci:test": "npm run test"
40
40
  },
41
41
  "dependencies": {
42
- "@landstrip/landstrip-api": "^0.19.13",
42
+ "@landstrip/landstrip-api": "^0.19.14",
43
43
  "jsonc-parser": "^3.3.1",
44
44
  "minimatch": "^10.2.5",
45
45
  "undici": "^8.9.0",