pi-landstrip 0.18.14 → 0.18.16

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 +18 -6
  2. package/api.ts +105 -44
  3. package/dist/index.ts +163 -46
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -168,18 +168,30 @@ Remove it with `npx @landstrip/landstrip windows uninstall`.
168
168
 
169
169
  ## Plugin API
170
170
 
171
- Other Pi extensions can discover the runtime with `useLandstrip` from
172
- `pi-landstrip/api`. The versioned runtime provides:
171
+ Other Pi extensions can discover the v2 runtime with `useLandstrip` from
172
+ `pi-landstrip/api`. The runtime provides:
173
173
 
174
174
  - `getContext()` for sandbox and task context.
175
- - `createBashTool()` for Landstrip-backed command tools.
175
+ - `registerShellProvider()` for replacing POSIX shell invocation preparation.
176
176
  - `prepareProcess()` for one policy-aware process launch.
177
177
  - `registerWorkerExtension()` for trusted worker extensions.
178
178
  - `on()` for sandbox and subagent lifecycle events.
179
179
 
180
- Discovery is callback-based and independent of extension load order. Worker
181
- extensions are trusted code. Workers also receive an informational
182
- `LANDSTRIP_CONTEXT` environment value; it is not proof of authorization.
180
+ A shell provider receives the command, working directory, composed command
181
+ environment, and abort signal. It returns an executable, arguments, minimal
182
+ launcher environment, required read paths, and optional cleanup. Landstrip adds
183
+ platform variables required by its launcher, such as Windows `ProgramData`.
184
+ Providers should pass the composed environment through a private temporary file,
185
+ include that file in `readPaths`, and remove it in `dispose()`; do not copy secrets
186
+ into `launcherEnv`. Landstrip remains responsible for starting the process, applying
187
+ policy, proxying network access, handling traps, and prompting for additional
188
+ access. Only one external provider can be active; disposing it restores the
189
+ built-in POSIX provider.
190
+
191
+ Use `provideLandstripShell()` to register a provider independently of extension
192
+ load order. Shell providers and worker extensions are trusted code. Workers also
193
+ receive an informational `LANDSTRIP_CONTEXT` environment value; it is not proof
194
+ of authorization.
183
195
 
184
196
  ## Limits
185
197
 
package/api.ts CHANGED
@@ -2,26 +2,20 @@
2
2
  // Copyright (C) Jarkko Sakkinen 2026
3
3
 
4
4
  import type { ChildProcess, SpawnOptions } from 'node:child_process';
5
+ import type { Readable, Writable } from 'node:stream';
5
6
 
6
- import {
7
- createBashToolDefinition,
8
- type ExtensionAPI,
9
- type ExtensionContext,
10
- } from '@earendil-works/pi-coding-agent';
11
-
12
- import type { RpcChildProcess } from './rpc-process.ts';
7
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
13
8
 
14
9
  export const LANDSTRIP_CONTEXT_ENV = 'LANDSTRIP_CONTEXT';
15
- export const LANDSTRIP_RUNTIME_VERSION = 1;
10
+ export const LANDSTRIP_RUNTIME_VERSION = 2;
16
11
 
17
- const RUNTIME_REGISTER_EVENT = 'landstrip:runtime:register:v1';
18
- const RUNTIME_DISCOVER_EVENT = 'landstrip:runtime:discover:v1';
12
+ const RUNTIME_REGISTER_EVENT = 'landstrip:runtime:register:v2';
13
+ const RUNTIME_DISCOVER_EVENT = 'landstrip:runtime:discover:v2';
19
14
 
20
15
  export type LandstripSandboxState = 'enabled' | 'disabled' | 'unavailable';
21
- export type LandstripBashTool = ReturnType<typeof createBashToolDefinition>;
22
16
 
23
- export interface LandstripContextV1 {
24
- readonly version: 1;
17
+ export interface LandstripContextV2 {
18
+ readonly version: 2;
25
19
  readonly host: 'pi';
26
20
  readonly role: 'primary' | 'subagent';
27
21
  readonly sandbox: LandstripSandboxState;
@@ -38,6 +32,30 @@ export interface LandstripWorkerExtension {
38
32
  readonly entry: string;
39
33
  }
40
34
 
35
+ export interface LandstripShellPrepareOptions {
36
+ readonly command: string;
37
+ readonly cwd: string;
38
+ /** Composed command environment; transport it without copying secrets into launcherEnv. */
39
+ readonly env: NodeJS.ProcessEnv;
40
+ readonly signal?: AbortSignal;
41
+ }
42
+
43
+ export interface LandstripShellInvocation {
44
+ readonly executable: string;
45
+ readonly args: readonly string[];
46
+ /** Minimal environment for the Landstrip launcher and shell bootstrap process. */
47
+ readonly launcherEnv: NodeJS.ProcessEnv;
48
+ readonly readPaths?: readonly string[];
49
+ dispose?(): void | Promise<void>;
50
+ }
51
+
52
+ export interface LandstripShellProvider {
53
+ readonly id: string;
54
+ prepare(
55
+ options: LandstripShellPrepareOptions,
56
+ ): LandstripShellInvocation | Promise<LandstripShellInvocation>;
57
+ }
58
+
41
59
  export interface LandstripProcessOptions {
42
60
  readonly command: string;
43
61
  readonly args: readonly string[];
@@ -58,25 +76,42 @@ export interface LandstripPreparedProcess {
58
76
  dispose(): Promise<void>;
59
77
  }
60
78
 
79
+ export interface LandstripRpcChildProcess {
80
+ readonly stdin: Writable;
81
+ readonly stdout: Readable;
82
+ readonly stderr: Readable;
83
+ readonly exitCode: number | null;
84
+ readonly signalCode: NodeJS.Signals | null;
85
+ kill(signal?: NodeJS.Signals | number): boolean;
86
+ on(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
87
+ on(event: 'error', listener: (error: Error) => void): this;
88
+ once(event: 'spawn', listener: () => void): this;
89
+ once(event: 'error', listener: (error: Error) => void): this;
90
+ once(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
91
+ off(event: 'spawn', listener: () => void): this;
92
+ off(event: 'error', listener: (error: Error) => void): this;
93
+ off(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
94
+ }
95
+
61
96
  export type LandstripSpawn = (
62
97
  command: string,
63
98
  args: readonly string[],
64
99
  options: SpawnOptions,
65
- ) => ChildProcess | RpcChildProcess;
100
+ ) => ChildProcess | LandstripRpcChildProcess;
66
101
 
67
102
  export type LandstripEvent =
68
- | { readonly type: 'sandbox.changed'; readonly context: LandstripContextV1 }
69
- | { readonly type: 'subagent.start'; readonly context: LandstripContextV1 }
103
+ | { readonly type: 'sandbox.changed'; readonly context: LandstripContextV2 }
104
+ | { readonly type: 'subagent.start'; readonly context: LandstripContextV2 }
70
105
  | {
71
106
  readonly type: 'subagent.end';
72
- readonly context: LandstripContextV1;
107
+ readonly context: LandstripContextV2;
73
108
  readonly status: 'completed' | 'cancelled' | 'error';
74
109
  };
75
110
 
76
- export interface PiLandstripRuntimeV1 {
77
- readonly version: 1;
78
- getContext(ctx?: ExtensionContext): LandstripContextV1;
79
- createBashTool(cwd: string, ctx?: ExtensionContext): LandstripBashTool;
111
+ export interface PiLandstripRuntimeV2 {
112
+ readonly version: 2;
113
+ getContext(ctx?: ExtensionContext): LandstripContextV2;
114
+ registerShellProvider(provider: LandstripShellProvider): () => void;
80
115
  prepareProcess(options: LandstripProcessOptions): Promise<LandstripPreparedProcess>;
81
116
  registerWorkerExtension(extension: LandstripWorkerExtension): () => void;
82
117
  getWorkerExtensions(): readonly LandstripWorkerExtension[];
@@ -87,13 +122,13 @@ export interface PiLandstripRuntimeV1 {
87
122
  }
88
123
 
89
124
  interface RuntimeRegistration {
90
- readonly version: 1;
91
- readonly runtime: PiLandstripRuntimeV1;
125
+ readonly version: 2;
126
+ readonly runtime: PiLandstripRuntimeV2;
92
127
  }
93
128
 
94
129
  interface RuntimeDiscovery {
95
- readonly version: 1;
96
- register(runtime: PiLandstripRuntimeV1): void;
130
+ readonly version: 2;
131
+ register(runtime: PiLandstripRuntimeV2): void;
97
132
  }
98
133
 
99
134
  interface EventBusLike {
@@ -109,12 +144,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
109
144
  return typeof value === 'object' && value !== null && !Array.isArray(value);
110
145
  }
111
146
 
112
- function isRuntime(value: unknown): value is PiLandstripRuntimeV1 {
147
+ function isRuntime(value: unknown): value is PiLandstripRuntimeV2 {
113
148
  return (
114
149
  isRecord(value) &&
115
150
  value.version === LANDSTRIP_RUNTIME_VERSION &&
116
151
  typeof value.getContext === 'function' &&
117
- typeof value.createBashTool === 'function' &&
152
+ typeof value.registerShellProvider === 'function' &&
118
153
  typeof value.prepareProcess === 'function' &&
119
154
  typeof value.registerWorkerExtension === 'function' &&
120
155
  typeof value.getWorkerExtensions === 'function' &&
@@ -124,13 +159,13 @@ function isRuntime(value: unknown): value is PiLandstripRuntimeV1 {
124
159
 
125
160
  export function useLandstrip(
126
161
  pi: ExtensionAPI,
127
- callback: (runtime: PiLandstripRuntimeV1) => void,
162
+ callback: (runtime: PiLandstripRuntimeV2) => void,
128
163
  ): () => void {
129
164
  const events = eventBus(pi);
130
165
  if (!events) return () => undefined;
131
166
 
132
- let current: PiLandstripRuntimeV1 | undefined;
133
- const register = (runtime: PiLandstripRuntimeV1): void => {
167
+ let current: PiLandstripRuntimeV2 | undefined;
168
+ const register = (runtime: PiLandstripRuntimeV2): void => {
134
169
  if (runtime === current) return;
135
170
  current = runtime;
136
171
  callback(runtime);
@@ -139,16 +174,37 @@ export function useLandstrip(
139
174
  if (!isRecord(value) || value.version !== LANDSTRIP_RUNTIME_VERSION) return;
140
175
  if (isRuntime(value.runtime)) register(value.runtime);
141
176
  });
142
- events.emit(RUNTIME_DISCOVER_EVENT, {
143
- version: LANDSTRIP_RUNTIME_VERSION,
144
- register,
145
- } satisfies RuntimeDiscovery);
146
- return unsubscribe;
177
+ try {
178
+ events.emit(RUNTIME_DISCOVER_EVENT, {
179
+ version: LANDSTRIP_RUNTIME_VERSION,
180
+ register,
181
+ } satisfies RuntimeDiscovery);
182
+ return unsubscribe;
183
+ } catch (error) {
184
+ unsubscribe();
185
+ throw error;
186
+ }
187
+ }
188
+
189
+ export function provideLandstripShell(
190
+ pi: ExtensionAPI,
191
+ provider: LandstripShellProvider,
192
+ ): () => void {
193
+ let unregisterProvider: (() => void) | undefined;
194
+ const stopDiscovery = useLandstrip(pi, (runtime) => {
195
+ const unregisterNext = runtime.registerShellProvider(provider);
196
+ unregisterProvider?.();
197
+ unregisterProvider = unregisterNext;
198
+ });
199
+ return () => {
200
+ stopDiscovery();
201
+ unregisterProvider?.();
202
+ };
147
203
  }
148
204
 
149
205
  export function publishLandstripRuntime(
150
206
  pi: ExtensionAPI,
151
- runtime: PiLandstripRuntimeV1,
207
+ runtime: PiLandstripRuntimeV2,
152
208
  ): () => void {
153
209
  const events = eventBus(pi);
154
210
  if (!events) return () => undefined;
@@ -157,20 +213,25 @@ export function publishLandstripRuntime(
157
213
  if (!isRecord(value) || value.version !== LANDSTRIP_RUNTIME_VERSION) return;
158
214
  if (typeof value.register === 'function') value.register(runtime);
159
215
  });
160
- events.emit(RUNTIME_REGISTER_EVENT, {
161
- version: LANDSTRIP_RUNTIME_VERSION,
162
- runtime,
163
- } satisfies RuntimeRegistration);
164
- return unsubscribe;
216
+ try {
217
+ events.emit(RUNTIME_REGISTER_EVENT, {
218
+ version: LANDSTRIP_RUNTIME_VERSION,
219
+ runtime,
220
+ } satisfies RuntimeRegistration);
221
+ return unsubscribe;
222
+ } catch (error) {
223
+ unsubscribe();
224
+ throw error;
225
+ }
165
226
  }
166
227
 
167
- export function encodeLandstripContext(context: LandstripContextV1): string {
228
+ export function encodeLandstripContext(context: LandstripContextV2): string {
168
229
  return Buffer.from(JSON.stringify(context)).toString('base64url');
169
230
  }
170
231
 
171
232
  export function contextFromEnvironment(
172
233
  env: NodeJS.ProcessEnv = process.env,
173
- ): LandstripContextV1 | undefined {
234
+ ): LandstripContextV2 | undefined {
174
235
  const encoded = env[LANDSTRIP_CONTEXT_ENV];
175
236
  if (!encoded) return undefined;
176
237
 
@@ -191,7 +252,7 @@ export function contextFromEnvironment(
191
252
  ) {
192
253
  return undefined;
193
254
  }
194
- return value as unknown as LandstripContextV1;
255
+ return value as unknown as LandstripContextV2;
195
256
  } catch {
196
257
  return undefined;
197
258
  }
package/dist/index.ts CHANGED
@@ -36,9 +36,9 @@ import {
36
36
 
37
37
  // api.ts
38
38
  var LANDSTRIP_CONTEXT_ENV = "LANDSTRIP_CONTEXT";
39
- var LANDSTRIP_RUNTIME_VERSION = 1;
40
- var RUNTIME_REGISTER_EVENT = "landstrip:runtime:register:v1";
41
- var RUNTIME_DISCOVER_EVENT = "landstrip:runtime:discover:v1";
39
+ var LANDSTRIP_RUNTIME_VERSION = 2;
40
+ var RUNTIME_REGISTER_EVENT = "landstrip:runtime:register:v2";
41
+ var RUNTIME_DISCOVER_EVENT = "landstrip:runtime:discover:v2";
42
42
  function eventBus(pi) {
43
43
  return pi.events;
44
44
  }
@@ -57,11 +57,16 @@ function publishLandstripRuntime(pi, runtime) {
57
57
  if (typeof value.register === "function")
58
58
  value.register(runtime);
59
59
  });
60
- events.emit(RUNTIME_REGISTER_EVENT, {
61
- version: LANDSTRIP_RUNTIME_VERSION,
62
- runtime
63
- });
64
- return unsubscribe;
60
+ try {
61
+ events.emit(RUNTIME_REGISTER_EVENT, {
62
+ version: LANDSTRIP_RUNTIME_VERSION,
63
+ runtime
64
+ });
65
+ return unsubscribe;
66
+ } catch (error) {
67
+ unsubscribe();
68
+ throw error;
69
+ }
65
70
  }
66
71
  function encodeLandstripContext(context) {
67
72
  return Buffer.from(JSON.stringify(context)).toString("base64url");
@@ -3510,7 +3515,7 @@ ${catalog.diagnostics.join(`
3510
3515
  }
3511
3516
  taskContext(task, ctx) {
3512
3517
  const context = this.integration.getContext?.(ctx) ?? {
3513
- version: 1,
3518
+ version: 2,
3514
3519
  host: "pi",
3515
3520
  role: "primary",
3516
3521
  sandbox: "unavailable",
@@ -4275,7 +4280,7 @@ function extractNativeDeniedPath(output, cwd) {
4275
4280
  }
4276
4281
  function extractNativeWriteDeniedPath(output, cwd) {
4277
4282
  const denial = String.raw`(?:Access is denied\.?|Operation not permitted|Permission denied)`;
4278
- let match = output.match(new RegExp(String.raw`(?:[Uu]nable to create|cannot (?:create|touch|mkdir|remove|unlink|rename)|for writing)[^'"\n]*['"]([^'"\n]+)['"][^\r\n]{0,120}${denial}`, "im"));
4283
+ let match = output.match(new RegExp(String.raw`(?:[Uu]nable to create|failed to create(?: directory)?|cannot (?:create|touch|mkdir|remove|unlink|rename)|for writing)[^'"\x60\r\n]*['"\x60]([^'"\x60\r\n]+)['"\x60](?:[^\r\n]*\r?\n){0,3}[^\r\n]{0,120}${denial}`, "im"));
4279
4284
  if (match)
4280
4285
  return normalizePathMatch(match[1], cwd);
4281
4286
  match = output.match(new RegExp(String.raw`^[a-zA-Z0-9_-]+: cannot create(?: directory)? '?([^'\n]+?)'?(?: for writing)?: ${denial}$`, "im"));
@@ -4488,6 +4493,51 @@ function writeEnvFile(env, proxyPort, proxyToken) {
4488
4493
  `), "utf-8");
4489
4494
  return { dir, path };
4490
4495
  }
4496
+ var POSIX_SHELL_PROVIDER_ID = "posix";
4497
+ function createPosixShellProvider() {
4498
+ return {
4499
+ id: POSIX_SHELL_PROVIDER_ID,
4500
+ prepare(options) {
4501
+ if (options.signal?.aborted)
4502
+ throw new Error("aborted");
4503
+ const { shell, args } = getShellConfig(SettingsManager.create(options.cwd).getShellPath());
4504
+ const envFile = writeEnvFile(options.env, null);
4505
+ let disposed = false;
4506
+ return {
4507
+ executable: shell,
4508
+ args: [...args, `source '${envFile.path}' && ${options.command}`],
4509
+ launcherEnv: { PATH: process.env.PATH, HOME: process.env.HOME },
4510
+ readPaths: [envFile.path],
4511
+ dispose() {
4512
+ if (disposed)
4513
+ return;
4514
+ disposed = true;
4515
+ rmSync2(envFile.dir, { recursive: true, force: true });
4516
+ }
4517
+ };
4518
+ }
4519
+ };
4520
+ }
4521
+ function shellEnvironment(env, cwd, proxyPort, proxyToken) {
4522
+ const result = { ...process.env, ...env, PWD: resolve3(cwd) };
4523
+ if (proxyPort === null)
4524
+ return result;
4525
+ const credentials = proxyToken === undefined ? "" : `landstrip:${proxyToken}@`;
4526
+ const url = `http://${credentials}127.0.0.1:${proxyPort}`;
4527
+ for (const name of PROXY_ENVIRONMENT_VARIABLES)
4528
+ result[name] = url;
4529
+ result.NO_PROXY = "";
4530
+ result.no_proxy = "";
4531
+ return result;
4532
+ }
4533
+ function createLandstripLauncherEnvironment(providerEnv, hostEnv = process.env, platform = process.platform) {
4534
+ if (platform !== "win32" || hostEnv.ProgramData === undefined)
4535
+ return providerEnv;
4536
+ return { ...providerEnv, ProgramData: hostEnv.ProgramData };
4537
+ }
4538
+ function createWindowsDenyRead(denyRead, volumeRoot) {
4539
+ return [...new Set([...denyRead.filter((path) => !path.startsWith("/")), volumeRoot])];
4540
+ }
4491
4541
  function parseProxyPort(value, defaultPort) {
4492
4542
  const rawPort = value ?? String(defaultPort);
4493
4543
  if (!/^\d+$/.test(rawPort))
@@ -4576,6 +4626,8 @@ function createLandstripIntegration(options = {}) {
4576
4626
  let unsandboxedWorkerWarningShown = false;
4577
4627
  let activeContext;
4578
4628
  let unpublishRuntime;
4629
+ const defaultShellProvider = createPosixShellProvider();
4630
+ let externalShellProvider;
4579
4631
  const eventHandlers = new Set;
4580
4632
  const workerExtensions = new Map;
4581
4633
  const sessionAllowedDomains = [];
@@ -4617,6 +4669,29 @@ function createLandstripIntegration(options = {}) {
4617
4669
  eventHandlers.add(listener);
4618
4670
  return () => eventHandlers.delete(listener);
4619
4671
  }
4672
+ function registerShellProvider(provider) {
4673
+ const id = provider.id.trim();
4674
+ if (!id)
4675
+ throw new Error("Shell provider id must not be empty");
4676
+ if (id === POSIX_SHELL_PROVIDER_ID) {
4677
+ throw new Error(`Shell provider id "${POSIX_SHELL_PROVIDER_ID}" is reserved`);
4678
+ }
4679
+ if (externalShellProvider) {
4680
+ throw new Error(`Shell provider "${externalShellProvider.id}" is already registered; cannot register "${id}"`);
4681
+ }
4682
+ externalShellProvider = provider;
4683
+ let disposed = false;
4684
+ return () => {
4685
+ if (disposed)
4686
+ return;
4687
+ disposed = true;
4688
+ if (externalShellProvider === provider)
4689
+ externalShellProvider = undefined;
4690
+ };
4691
+ }
4692
+ function activeShellProvider() {
4693
+ return externalShellProvider ?? defaultShellProvider;
4694
+ }
4620
4695
  function registerWorkerExtension(extension) {
4621
4696
  const id = extension.id.trim();
4622
4697
  if (!id)
@@ -4677,7 +4752,7 @@ function createLandstripIntegration(options = {}) {
4677
4752
  if (process.platform !== "win32")
4678
4753
  return config.filesystem.denyRead;
4679
4754
  const volumeRoot = parse(resolve3(cwd)).root;
4680
- return [...new Set([...config.filesystem.denyRead, volumeRoot])];
4755
+ return createWindowsDenyRead(config.filesystem.denyRead, volumeRoot);
4681
4756
  }
4682
4757
  function hasBaselineWindowsApplicationAccess(filePath) {
4683
4758
  const normalizedPath = normalizePathSeparators(resolve3(filePath)).toLowerCase();
@@ -4786,13 +4861,13 @@ function createLandstripIntegration(options = {}) {
4786
4861
  `, "utf-8");
4787
4862
  return { dir, path };
4788
4863
  }
4789
- function startProxy(ctx, allowances, signal, authorization, portRange) {
4864
+ function startProxy(ctx, cwd, allowances, signal, authorization, portRange) {
4790
4865
  const sockets = new Set;
4791
4866
  async function domainAllowed(domain) {
4792
- const config = loadConfig(ctx.cwd);
4867
+ const config = loadConfig(cwd);
4793
4868
  if (domainMatchesAny(domain, config.network.deniedDomains))
4794
4869
  return false;
4795
- return ensureDomainAllowed(ctx, domain, ctx.cwd, allowances, signal);
4870
+ return ensureDomainAllowed(ctx, domain, cwd, allowances, signal);
4796
4871
  }
4797
4872
  function trackUpstream(upstream, client, settled) {
4798
4873
  sockets.add(upstream);
@@ -5120,7 +5195,7 @@ function createLandstripIntegration(options = {}) {
5120
5195
  const config = loadConfig(options2.cwd);
5121
5196
  const proxyToken = randomBytes(32).toString("base64url");
5122
5197
  const proxyAuthorization = `Basic ${Buffer.from(`landstrip:${proxyToken}`).toString("base64")}`;
5123
- const proxy = shouldStartProxy(config) ? await startProxy(options2.ctx, processAllowances, options2.signal, proxyAuthorization, restrictedUserProxyPortRange()) : null;
5198
+ const proxy = shouldStartProxy(config) ? await startProxy(options2.ctx, options2.cwd, processAllowances, options2.signal, proxyAuthorization, restrictedUserProxyPortRange()) : null;
5124
5199
  let policy;
5125
5200
  let trapSocket;
5126
5201
  let childEnd;
@@ -5222,32 +5297,55 @@ function createLandstripIntegration(options = {}) {
5222
5297
  async exec(command, cwd, { onData, signal, timeout, env }) {
5223
5298
  if (!existsSync5(cwd))
5224
5299
  throw new Error(`Working directory does not exist: ${cwd}`);
5225
- const { shell, args } = getShellConfig(SettingsManager.create(cwd).getShellPath());
5300
+ const provider = activeShellProvider();
5226
5301
  const config = loadConfig(cwd);
5227
5302
  const proxyToken = randomBytes(32).toString("base64url");
5228
5303
  const proxyAuthorization = `Basic ${Buffer.from(`landstrip:${proxyToken}`).toString("base64")}`;
5229
- const proxy = shouldStartProxy(config) ? await startProxy(ctx, allowances, signal, proxyAuthorization, restrictedUserProxyPortRange()) : null;
5304
+ const proxy = shouldStartProxy(config) ? await startProxy(ctx, cwd, allowances, signal, proxyAuthorization, restrictedUserProxyPortRange()) : null;
5230
5305
  let policy;
5231
- let envFile;
5306
+ let invocation;
5232
5307
  let processAllowances = allowances;
5233
5308
  let teardownPromise;
5234
5309
  const teardownResources = () => {
5235
- teardownPromise ??= (async () => {
5236
- await proxy?.stop();
5237
- if (policy)
5238
- rmSync2(policy.dir, { recursive: true, force: true });
5239
- if (envFile)
5240
- rmSync2(envFile.dir, { recursive: true, force: true });
5241
- })();
5310
+ teardownPromise ??= Promise.all([
5311
+ Promise.resolve().then(() => invocation?.dispose?.()),
5312
+ Promise.resolve().then(() => {
5313
+ if (policy)
5314
+ rmSync2(policy.dir, { recursive: true, force: true });
5315
+ }),
5316
+ Promise.resolve().then(() => proxy?.stop())
5317
+ ]).then(() => {
5318
+ return;
5319
+ });
5242
5320
  return teardownPromise;
5243
5321
  };
5244
5322
  let landstripArgs;
5245
5323
  try {
5246
- envFile = writeEnvFile({ ...process.env, ...env, PWD: resolve3(cwd) }, proxy?.port ?? null, proxy ? proxyToken : undefined);
5247
- processAllowances = withWindowsProcessReadAccess(allowances, shell, cwd, [envFile.path]);
5324
+ invocation = await provider.prepare({
5325
+ command,
5326
+ cwd,
5327
+ env: shellEnvironment(env, cwd, proxy?.port ?? null, proxy ? proxyToken : undefined),
5328
+ signal
5329
+ });
5330
+ if (!invocation.executable.trim()) {
5331
+ throw new Error(`Shell provider "${provider.id}" returned an empty executable`);
5332
+ }
5333
+ if (signal?.aborted)
5334
+ throw new Error("aborted");
5335
+ processAllowances = {
5336
+ ...allowances,
5337
+ readPaths: [...allowances.readPaths, ...invocation.readPaths ?? []]
5338
+ };
5339
+ processAllowances = withWindowsProcessReadAccess(processAllowances, invocation.executable, cwd);
5248
5340
  policy = writePolicyFile(cwd, proxy?.port ?? null, processAllowances);
5249
- const wrappedCommand = `source '${envFile.path}' && ${command}`;
5250
- landstripArgs = ["run", "-p", policy.path, "--", shell, ...args, wrappedCommand];
5341
+ landstripArgs = [
5342
+ "run",
5343
+ "-p",
5344
+ policy.path,
5345
+ "--",
5346
+ invocation.executable,
5347
+ ...invocation.args
5348
+ ];
5251
5349
  if (process.platform !== "win32")
5252
5350
  landstripArgs.splice(1, 0, "--trap-fd", "3");
5253
5351
  } catch (error) {
@@ -5255,14 +5353,19 @@ function createLandstripIntegration(options = {}) {
5255
5353
  throw error;
5256
5354
  }
5257
5355
  return new Promise((resolvePromise, reject) => {
5356
+ let pendingSocketPair;
5258
5357
  (async () => {
5259
5358
  let timeoutHandle;
5260
5359
  let timedOut = false;
5261
5360
  let cleaned = false;
5262
5361
  let trapSocket;
5263
5362
  let childEnd;
5264
- if (process.platform !== "win32")
5265
- [trapSocket, childEnd] = await createSocketPair();
5363
+ if (process.platform !== "win32") {
5364
+ pendingSocketPair = await createSocketPair();
5365
+ [trapSocket, childEnd] = pendingSocketPair;
5366
+ }
5367
+ if (signal?.aborted)
5368
+ throw new Error("aborted");
5266
5369
  const cleanup = async () => {
5267
5370
  if (cleaned)
5268
5371
  return;
@@ -5276,12 +5379,13 @@ function createLandstripIntegration(options = {}) {
5276
5379
  const stdio = childEnd ? ["ignore", "pipe", "pipe", childEnd] : ["ignore", "pipe", "pipe"];
5277
5380
  const child = spawn2(binaryPath(), landstripArgs, {
5278
5381
  cwd,
5279
- env: { PATH: process.env.PATH, HOME: process.env.HOME },
5382
+ env: createLandstripLauncherEnvironment(invocation.launcherEnv),
5280
5383
  detached: true,
5281
5384
  stdio
5282
5385
  });
5283
5386
  patchProcessGroupKill(child);
5284
5387
  childEnd?.destroy();
5388
+ pendingSocketPair = undefined;
5285
5389
  function killChild() {
5286
5390
  child.kill("SIGKILL");
5287
5391
  }
@@ -5295,6 +5399,8 @@ function createLandstripIntegration(options = {}) {
5295
5399
  }, timeout * 1000);
5296
5400
  }
5297
5401
  signal?.addEventListener("abort", onAbort, { once: true });
5402
+ if (signal?.aborted)
5403
+ onAbort();
5298
5404
  let stderrAcc = "";
5299
5405
  let errorFdAcc = "";
5300
5406
  let execSettled = false;
@@ -5419,9 +5525,9 @@ function createLandstripIntegration(options = {}) {
5419
5525
  }
5420
5526
  });
5421
5527
  child.once("close", (code) => finalizeExec(code));
5422
- })().catch(async (error) => {
5423
- await teardownResources();
5424
- reject(error);
5528
+ })().catch((error) => {
5529
+ pendingSocketPair?.forEach((socket) => socket.destroy());
5530
+ teardownResources().then(() => reject(error), (cleanupError) => reject(new AggregateError([error, cleanupError], "Shell setup and cleanup failed")));
5425
5531
  });
5426
5532
  });
5427
5533
  }
@@ -5662,19 +5768,22 @@ ${message}
5662
5768
  return enableSandbox(ctx);
5663
5769
  return true;
5664
5770
  }
5665
- function createBashTool(cwd, ctx, requireSandbox = false) {
5771
+ function createBashTool(cwd) {
5666
5772
  const localBash = createPlainBashTool(cwd);
5667
5773
  return {
5668
5774
  ...localBash,
5669
5775
  label: "bash (landstrip)",
5670
- async execute(id, params, signal, onUpdate, callCtx) {
5671
- const effectiveCtx = ctx ?? callCtx;
5672
- if (!effectiveCtx || !ensureSandboxState(effectiveCtx)) {
5673
- if (requireSandbox)
5674
- throw new Error("Sandbox is unavailable; refusing subagent command");
5675
- return localBash.execute(id, params, signal, onUpdate, effectiveCtx);
5776
+ description: "Execute commands with the active Landstrip shell provider.",
5777
+ async execute(id, params, signal, onUpdate, ctx) {
5778
+ if (!ctx)
5779
+ throw new Error("Sandbox context is unavailable");
5780
+ if (!ensureSandboxState(ctx)) {
5781
+ if (sandboxState === "unavailable") {
5782
+ throw new Error("Sandbox is unavailable; refusing command");
5783
+ }
5784
+ return localBash.execute(id, params, signal, onUpdate, ctx);
5676
5785
  }
5677
- return runBashWithOptionalRetry(id, params, signal, onUpdate, effectiveCtx);
5786
+ return runBashWithOptionalRetry(id, params, signal, onUpdate, ctx);
5678
5787
  }
5679
5788
  };
5680
5789
  }
@@ -5690,8 +5799,12 @@ ${message}
5690
5799
  if (shouldRegisterBashTool)
5691
5800
  pi.registerTool(createBashTool(localCwd));
5692
5801
  pi.on("user_bash", async (_event, ctx) => {
5693
- if (!ensureSandboxState(ctx))
5802
+ if (!ensureSandboxState(ctx)) {
5803
+ if (sandboxState === "unavailable") {
5804
+ throw new Error("Sandbox is unavailable; refusing command");
5805
+ }
5694
5806
  return;
5807
+ }
5695
5808
  return { operations: createLandstripBashOps(ctx, { promptOnBlock: true }) };
5696
5809
  });
5697
5810
  pi.on("session_start", async (_event, ctx) => {
@@ -5844,7 +5957,7 @@ ${target.path}`);
5844
5957
  const integration = {
5845
5958
  version: LANDSTRIP_RUNTIME_VERSION,
5846
5959
  getContext,
5847
- createBashTool,
5960
+ registerShellProvider,
5848
5961
  prepareProcess,
5849
5962
  prepareRpcWorker,
5850
5963
  registerWorkerExtension,
@@ -5870,6 +5983,10 @@ export {
5870
5983
  extractNativeDeniedPath,
5871
5984
  domainMatchesAny,
5872
5985
  pi_landstrip_default as default,
5986
+ createWindowsDenyRead,
5987
+ createPosixShellProvider,
5988
+ createLandstripLauncherEnvironment,
5873
5989
  createLandstripIntegration,
5874
- controlResponseLine
5990
+ controlResponseLine,
5991
+ POSIX_SHELL_PROVIDER_ID
5875
5992
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.18.14",
3
+ "version": "0.18.16",
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": "^0.18.14",
42
+ "@landstrip/landstrip": "^0.18.16",
43
43
  "jsonc-parser": "^3.3.1",
44
44
  "minimatch": "^10.2.5",
45
45
  "yaml": "^2.9.0"