pi-studio 0.9.59 → 0.9.60-rc.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.60-rc.0] — 2026-09-03
8
+
9
+ ### Added
10
+ - Add explicit `/studio --listen-all` support for container and trusted private-network workflows. Studio binds to `0.0.0.0` only when requested, retains token authentication, keeps generated browser URLs on `127.0.0.1` for same-port host publishing, reports the active listening address in `/studio --status`, and requires a stop/restart before changing a running localhost server to wildcard binding.
11
+
12
+ ### Security
13
+ - Wildcard network binding remains opt-in and emits a prominent warning that the tokenized URL grants control of Studio for the Pi process, including prompt submission and broad file access through Studio workflows. HTTP routing no longer derives its base authority from the inbound `Host` header. In wildcard mode, a browser-supplied HTTP(S) `Origin` must match that request's `Host`; originless non-browser or embedded clients continue to rely on the URL token. Documentation recommends loopback-only host port publishing, forbids direct public-internet exposure, and retains localhost plus SSH forwarding as the safe default.
14
+
7
15
  ## [0.9.59] — 2026-09-03
8
16
 
9
17
  ### Added
package/README.md CHANGED
@@ -83,8 +83,9 @@ When Pi accepts **Run editor text**, Studio clears the linked terminal draft onl
83
83
  | `/studio --last` | Force last response |
84
84
  | `/studio --blank` | Force blank editor |
85
85
  | `/studio --no-browser` | Start/print the Studio URL without opening a browser, useful for forwarded or phone/browser sessions |
86
- | `/studio --port <port>` | Bind Studio to a fixed localhost port instead of a random free port |
87
- | `/studio --status` | Show studio server status |
86
+ | `/studio --port <port>` | Bind Studio to a fixed port instead of a random free port; the default host remains localhost |
87
+ | `/studio --listen-all` | Explicitly bind Studio to all IPv4 interfaces (`0.0.0.0`) for a container or trusted private-network workflow |
88
+ | `/studio --status` | Show studio server status, including its listening address |
88
89
  | `/studio --stop` | Stop studio server |
89
90
  | `/studio --help` | Show help |
90
91
  | `/studio-replace [path\|--blank\|--last]` | Replace the current full Studio view with a new full Studio view |
@@ -118,6 +119,18 @@ Run once without installing:
118
119
  pi -e https://github.com/omaclaren/pi-studio
119
120
  ```
120
121
 
122
+ ## Container and network access
123
+
124
+ Studio binds to `127.0.0.1` by default. For a container or sandbox whose port must be published to the host, opt in explicitly and choose a stable port:
125
+
126
+ ```text
127
+ /studio --no-browser --listen-all --port 4321
128
+ ```
129
+
130
+ This listens on `0.0.0.0:4321` while still printing a tokenized `http://127.0.0.1:4321/...` browser URL. With a same-port mapping such as `sbx run --publish 4321:4321`, open that URL unchanged on the host. `--listen-all` is a server-lifetime setting: if Studio is already running on localhost, use `/studio --stop` before restarting it with the flag.
131
+
132
+ Treat wildcard binding as a security-sensitive mode. The URL token remains required, but anyone who obtains the full URL can control Studio for that Pi process: they can submit prompts, read or write files through Studio, and expand local-resource access. Studio does not add TLS. Treat the URL like a password, never expose the port directly to the public internet, and bind the host side of a container mapping to loopback when possible—for example, `-p 127.0.0.1:4321:4321`. For access across an untrusted network, keep Studio on its localhost default and use SSH local port forwarding instead.
133
+
121
134
  ## Shared REPL record
122
135
 
123
136
  Studio remains a standalone REPL client: it can create and use its own tmux sessions without `pi-repl`. When a compatible `pi-repl` installation uses the same tmux session, both clients automatically discover one session-owned clean record and see each other's code and captured output. No package imports or depends on the other.
@@ -220,7 +233,7 @@ Thanks to [Hal Gumbert / CampSoftware](https://github.com/campsoftware) for publ
220
233
 
221
234
  ## Notes
222
235
 
223
- - Local-only server (`127.0.0.1`) with tokenized Studio URLs.
236
+ - The Studio server is localhost-only (`127.0.0.1`) by default and every Studio URL carries a server-lifetime token. The explicit `--listen-all` option changes the listening address to `0.0.0.0` without changing the advertised loopback URL or removing token authentication; `/studio --status` reports the active binding.
224
237
  - When Pi runs inside Muxy or cmux, Studio opens in that terminal app’s built-in browser. Muxy is detected from its pane/socket environment without installing global hooks; cmux targets and focuses the caller’s workspace. If the detected terminal browser is unavailable, disabled, or declines the request, Studio falls back once to the system browser.
225
238
  - For remote SSH sessions, keep Studio bound to localhost and use SSH local port forwarding; `/studio` and `/studio --status` print the full tokenized localhost URL. The SSH hint repeats the full URL so it is visible even if your terminal only shows the latest notification. Open that URL through the tunnel, preserving the `?token=...` parameter. If SSH is not auto-detected, use `/studio --no-browser`; for stable forwarding, use `/studio --port <port>` or combine them, e.g. `/studio --no-browser --port 3417`.
226
239
  - Full Studio is a singleton per Pi session: use `/studio` to open it, `/studio-replace` to explicitly replace it, and `/studio-editor-only` for extra editing/preview tabs that do not take over the full Studio session view.
package/ROADMAP.md CHANGED
@@ -116,6 +116,17 @@ A small workflow refinement for moving Neovim-provided context through Pi and St
116
116
  - provide `Cmd/Ctrl+Shift+L` for loading and a confirmed **Clear Pi editor text…** fallback without changing Studio text or conversation history;
117
117
  - keep `pi-nvim-context`, protocol v1, networking, and the buffer-first architecture unchanged.
118
118
 
119
+ ## 0.9.60 — Opt-in container/network binding (`0.9.60-rc.0` prerelease, 2026-09-03)
120
+
121
+ A bounded implementation of GitHub issue #4, separate from REPL transcript and buffer work:
122
+
123
+ - retain `127.0.0.1` as the default and require explicit `--listen-all` intent before binding to `0.0.0.0`;
124
+ - keep the server-lifetime bearer token mandatory and the generated URL on loopback for same-port container publishing;
125
+ - keep inbound `Host` values out of route parsing and require wildcard-mode browser origins to match the request authority;
126
+ - report the actual listening address, emit a prominent exposure warning, and require `/studio --stop` before changing a running server's binding;
127
+ - document loopback-only host publishing, private-network limits, and SSH forwarding as the safer option on untrusted networks;
128
+ - validate both default loopback and opt-in wildcard sockets without changing the buffer model, protocol v1, or `pi-repl`.
129
+
119
130
  ## 0.10.0 — Buffer-first editing
120
131
 
121
132
  The first architectural `0.10` release should add:
package/index.ts CHANGED
@@ -46,6 +46,13 @@ import { createStudioPandocHtmlResourceFlagResolver } from "./shared/studio-pand
46
46
  import { createStudioResourceGrantRegistry } from "./shared/studio-resource-grants.js";
47
47
  import { prepareStudioLatexForPandoc } from "./shared/studio-latex-pandoc-compat.js";
48
48
  import { isStudioCmuxSession, openStudioUrlInBrowser } from "./shared/studio-browser-launcher.js";
49
+ import {
50
+ buildStudioListenAllWarning,
51
+ isStudioWebSocketOriginAllowed,
52
+ parseStudioRequestTarget,
53
+ resolveStudioNetworkBinding,
54
+ STUDIO_ADVERTISED_HOST,
55
+ } from "./shared/studio-network-binding.js";
49
56
  import { buildStudioReplTmuxStartArgs } from "./shared/studio-repl-tmux.js";
50
57
  import {
51
58
  REPL_SESSION_RECORD_ID_OPTION,
@@ -197,6 +204,8 @@ interface StudioServerState {
197
204
  clients: Set<WebSocket>;
198
205
  clientModes: Map<WebSocket, StudioUiMode>;
199
206
  port: number;
207
+ bindHost: string;
208
+ listenAll: boolean;
200
209
  token: string;
201
210
  }
202
211
 
@@ -11865,13 +11874,6 @@ function interruptStudioReplSession(sessionName: string): { ok: true; message: s
11865
11874
  return { ok: true, message: `Interrupted ${sessionName}.` };
11866
11875
  }
11867
11876
 
11868
- function isAllowedOrigin(_origin: string | undefined, _port: number): boolean {
11869
- // For local-only studio, token auth is the primary guard. In practice,
11870
- // browser origin headers can vary (or be omitted) across wrappers/browsers,
11871
- // so we avoid brittle origin-based rejection here.
11872
- return true;
11873
- }
11874
-
11875
11877
  function normalizeStudioUiMode(raw: string | null | undefined): StudioUiMode {
11876
11878
  return raw === "editor-only" ? "editor-only" : "full";
11877
11879
  }
@@ -11936,7 +11938,7 @@ function buildStudioUrl(
11936
11938
  docId?: string,
11937
11939
  options?: StudioUrlOptions,
11938
11940
  ): string {
11939
- return `http://127.0.0.1:${port}${buildStudioRelativeUrl(token, mode, doc, docId, options)}`;
11941
+ return `http://${STUDIO_ADVERTISED_HOST}:${port}${buildStudioRelativeUrl(token, mode, doc, docId, options)}`;
11940
11942
  }
11941
11943
 
11942
11944
  interface StudioLaunchFlags {
@@ -11944,17 +11946,19 @@ interface StudioLaunchFlags {
11944
11946
  openRemoteBrowser: boolean;
11945
11947
  noBrowser: boolean;
11946
11948
  watchPdf: boolean;
11949
+ listenAll: boolean;
11947
11950
  port?: number;
11948
11951
  error?: string;
11949
11952
  }
11950
11953
 
11951
11954
  function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
11952
11955
  const parsed = tokenizeStudioCommandArgs(rawArgs);
11953
- if (parsed.error) return { args: rawArgs, openRemoteBrowser: false, noBrowser: false, watchPdf: false, error: parsed.error };
11956
+ if (parsed.error) return { args: rawArgs, openRemoteBrowser: false, noBrowser: false, watchPdf: false, listenAll: false, error: parsed.error };
11954
11957
  const remaining: string[] = [];
11955
11958
  let openRemoteBrowser = false;
11956
11959
  let noBrowser = false;
11957
11960
  let watchPdf = false;
11961
+ let listenAll = false;
11958
11962
  let port: number | undefined;
11959
11963
  for (let i = 0; i < parsed.tokens.length; i += 1) {
11960
11964
  const token = parsed.tokens[i]!;
@@ -11970,14 +11974,18 @@ function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
11970
11974
  watchPdf = true;
11971
11975
  continue;
11972
11976
  }
11977
+ if (token === "--listen-all") {
11978
+ listenAll = true;
11979
+ continue;
11980
+ }
11973
11981
  if (token === "--port" || token.startsWith("--port=")) {
11974
11982
  const rawPort = token.startsWith("--port=") ? token.slice("--port=".length) : parsed.tokens[++i];
11975
11983
  if (!rawPort) {
11976
- return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, error: "Missing value for --port." };
11984
+ return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, listenAll, error: "Missing value for --port." };
11977
11985
  }
11978
11986
  const requestedPort = Number(rawPort);
11979
11987
  if (!Number.isInteger(requestedPort) || requestedPort < 1 || requestedPort > 65535) {
11980
- return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, error: `Invalid --port value: ${rawPort}. Use an integer from 1 to 65535.` };
11988
+ return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, listenAll, error: `Invalid --port value: ${rawPort}. Use an integer from 1 to 65535.` };
11981
11989
  }
11982
11990
  port = requestedPort;
11983
11991
  continue;
@@ -11985,9 +11993,9 @@ function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
11985
11993
  remaining.push(token);
11986
11994
  }
11987
11995
  if (openRemoteBrowser && noBrowser) {
11988
- return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, port, error: "Use either --no-browser or --open-browser, not both." };
11996
+ return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, listenAll, port, error: "Use either --no-browser or --open-browser, not both." };
11989
11997
  }
11990
- return { args: remaining.join(" "), openRemoteBrowser, noBrowser, watchPdf, port };
11998
+ return { args: remaining.join(" "), openRemoteBrowser, noBrowser, watchPdf, listenAll, port };
11991
11999
  }
11992
12000
 
11993
12001
  function shouldAutoOpenStudioBrowser(options?: { openRemoteBrowser?: boolean; noBrowser?: boolean }): boolean {
@@ -17446,12 +17454,9 @@ export default function (pi: ExtensionAPI) {
17446
17454
  return;
17447
17455
  }
17448
17456
 
17449
- let requestUrl: URL;
17450
- try {
17451
- const host = req.headers.host ?? `127.0.0.1:${serverState.port}`;
17452
- requestUrl = new URL(req.url ?? "/", `http://${host}`);
17453
- } catch (error) {
17454
- respondText(res, 400, `Invalid request URL: ${error instanceof Error ? error.message : String(error)}`);
17457
+ const requestUrl = parseStudioRequestTarget(req.url);
17458
+ if (!requestUrl) {
17459
+ respondText(res, 400, "Invalid request URL");
17455
17460
  return;
17456
17461
  }
17457
17462
 
@@ -18044,9 +18049,10 @@ export default function (pi: ExtensionAPI) {
18044
18049
  res.end(buildStudioHtml(requestInitialDocument, serverState.token, lastCommandCtx?.ui.theme, currentModelLabel, terminalSessionLabel, terminalSessionDetail, contextUsageSnapshot, studioMode));
18045
18050
  };
18046
18051
 
18047
- const ensureServer = async (requestedPort?: number): Promise<StudioServerState> => {
18052
+ const ensureServer = async (requestedPort?: number, listenAll = false): Promise<StudioServerState> => {
18048
18053
  if (serverState) return serverState;
18049
18054
 
18055
+ const binding = resolveStudioNetworkBinding(listenAll);
18050
18056
  const server = createServer(handleHttpRequest);
18051
18057
  const wsServer = new WebSocketServer({ noServer: true });
18052
18058
  const clients = new Set<WebSocket>();
@@ -18058,12 +18064,18 @@ export default function (pi: ExtensionAPI) {
18058
18064
  clients,
18059
18065
  clientModes,
18060
18066
  port: 0,
18067
+ bindHost: binding.bindHost,
18068
+ listenAll: binding.listenAll,
18061
18069
  token: createSessionToken(),
18062
18070
  };
18063
18071
 
18064
18072
  server.on("upgrade", (req, socket, head) => {
18065
- const host = req.headers.host ?? `127.0.0.1:${state.port}`;
18066
- const requestUrl = new URL(req.url ?? "/", `http://${host}`);
18073
+ const requestUrl = parseStudioRequestTarget(req.url);
18074
+ if (!requestUrl) {
18075
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
18076
+ socket.destroy();
18077
+ return;
18078
+ }
18067
18079
 
18068
18080
  if (requestUrl.pathname !== "/ws") {
18069
18081
  socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
@@ -18078,7 +18090,9 @@ export default function (pi: ExtensionAPI) {
18078
18090
  return;
18079
18091
  }
18080
18092
 
18081
- if (!isAllowedOrigin(req.headers.origin, state.port)) {
18093
+ const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined;
18094
+ const requestHost = typeof req.headers.host === "string" ? req.headers.host : undefined;
18095
+ if (!isStudioWebSocketOriginAllowed(origin, requestHost, state.listenAll)) {
18082
18096
  socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
18083
18097
  socket.destroy();
18084
18098
  return;
@@ -18090,8 +18104,15 @@ export default function (pi: ExtensionAPI) {
18090
18104
  });
18091
18105
 
18092
18106
  wsServer.on("connection", (ws, req) => {
18093
- const host = req.headers.host ?? `127.0.0.1:${state.port}`;
18094
- const requestUrl = new URL(req.url ?? "/ws", `http://${host}`);
18107
+ const requestUrl = parseStudioRequestTarget(req.url);
18108
+ if (!requestUrl) {
18109
+ try {
18110
+ ws.close(1008, "Invalid request URL");
18111
+ } catch {
18112
+ // Ignore close errors.
18113
+ }
18114
+ return;
18115
+ }
18095
18116
  const clientMode = normalizeStudioUiMode(requestUrl.searchParams.get("mode"));
18096
18117
  const requestedWatchedPath = (requestUrl.searchParams.get("watchPath") ?? "").trim();
18097
18118
  const canonicalWatchedPath = requestedWatchedPath ? resolve(requestedWatchedPath) : "";
@@ -18190,7 +18211,7 @@ export default function (pi: ExtensionAPI) {
18190
18211
  };
18191
18212
  server.once("error", onError);
18192
18213
  server.once("listening", onListening);
18193
- server.listen(listenPort, "127.0.0.1");
18214
+ server.listen(listenPort, state.bindHost);
18194
18215
  });
18195
18216
 
18196
18217
  const address = server.address();
@@ -19063,6 +19084,10 @@ export default function (pi: ExtensionAPI) {
19063
19084
  if (serverState && launchOpenFlags.port && serverState.port !== launchOpenFlags.port) {
19064
19085
  ctx.ui.notify(`Studio server is already running on port ${serverState.port}; requested port ${launchOpenFlags.port}. Use /studio --stop, then restart Studio with --port ${launchOpenFlags.port} to change it.`, "warning");
19065
19086
  }
19087
+ if (serverState && launchOpenFlags.listenAll && !serverState.listenAll) {
19088
+ ctx.ui.notify(`Studio server is already bound to localhost at 127.0.0.1:${serverState.port}. Use /studio --stop, then restart it with --listen-all.`, "warning");
19089
+ return;
19090
+ }
19066
19091
 
19067
19092
  const parsedLaunchPath = options?.allowPdfPreview ? parsePathArgument(launchArgs) : null;
19068
19093
  const launchesPdfPreview = parsedLaunchPath
@@ -19082,9 +19107,12 @@ export default function (pi: ExtensionAPI) {
19082
19107
  if (serverState) {
19083
19108
  const url = buildStudioUrl(serverState.port, serverState.token, "full");
19084
19109
  ctx.ui.notify(`Studio URL: ${url}`, "info");
19085
- const tunnelHint = buildStudioSshTunnelHint(serverState.port, url)
19086
- ?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(serverState.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
19110
+ const tunnelHint = serverState.listenAll
19111
+ ? null
19112
+ : buildStudioSshTunnelHint(serverState.port, url)
19113
+ ?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(serverState.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
19087
19114
  if (tunnelHint) ctx.ui.notify(tunnelHint, "info");
19115
+ if (serverState.listenAll) ctx.ui.notify(buildStudioListenAllWarning(serverState.port, url), "warning");
19088
19116
  }
19089
19117
  return;
19090
19118
  }
@@ -19117,7 +19145,7 @@ export default function (pi: ExtensionAPI) {
19117
19145
 
19118
19146
  let state: StudioServerState;
19119
19147
  try {
19120
- state = await ensureServer(launchOpenFlags.port);
19148
+ state = await ensureServer(launchOpenFlags.port, launchOpenFlags.listenAll);
19121
19149
  } catch (error) {
19122
19150
  const message = error instanceof Error ? error.message : String(error);
19123
19151
  const portText = launchOpenFlags.port ? ` on port ${launchOpenFlags.port}` : "";
@@ -19130,8 +19158,10 @@ export default function (pi: ExtensionAPI) {
19130
19158
  skipWorkspaceRestore: selection.skipWorkspaceRestore,
19131
19159
  paneFocus: selection.paneFocus,
19132
19160
  });
19133
- const tunnelHint = buildStudioSshTunnelHint(state.port, url)
19134
- ?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
19161
+ const tunnelHint = state.listenAll
19162
+ ? null
19163
+ : buildStudioSshTunnelHint(state.port, url)
19164
+ ?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
19135
19165
  const openedLabel = selection.kind === "pdf-preview"
19136
19166
  ? "pi Studio PDF preview"
19137
19167
  : (selection.kind === "watched-preview"
@@ -19171,11 +19201,12 @@ export default function (pi: ExtensionAPI) {
19171
19201
  } finally {
19172
19202
  ctx.ui.notify(`Studio URL: ${url}`, "info");
19173
19203
  if (tunnelHint) ctx.ui.notify(tunnelHint, "info");
19204
+ if (state.listenAll) ctx.ui.notify(buildStudioListenAllWarning(state.port, url), "warning");
19174
19205
  }
19175
19206
  };
19176
19207
 
19177
19208
  pi.registerCommand("studio", {
19178
- description: "Open pi Studio browser UI or a read-only watched preview (/studio, /studio <file>, /studio --watch <file>, /studio --blank, /studio --last, /studio --no-browser)",
19209
+ description: "Open pi Studio browser UI or a read-only watched preview (/studio, /studio <file>, /studio --watch <file>, /studio --blank, /studio --last, /studio --no-browser, /studio --listen-all)",
19179
19210
  handler: async (args: string, ctx: ExtensionCommandContext) => {
19180
19211
  const trimmed = args.trim();
19181
19212
 
@@ -19193,11 +19224,12 @@ export default function (pi: ExtensionAPI) {
19193
19224
  const counts = getStudioClientCounts();
19194
19225
  const url = buildStudioUrl(serverState.port, serverState.token, "full");
19195
19226
  ctx.ui.notify(
19196
- `Studio running at ${url} (busy: ${isStudioBusy() ? "yes" : "no"}; full views: ${counts.full}; editor-only views: ${counts.editorOnly})`,
19227
+ `Studio running at ${url} (listening on ${serverState.bindHost}:${serverState.port}; busy: ${isStudioBusy() ? "yes" : "no"}; full views: ${counts.full}; editor-only views: ${counts.editorOnly})`,
19197
19228
  "info",
19198
19229
  );
19199
- const sshTunnelHint = buildStudioSshTunnelHint(serverState.port, url);
19230
+ const sshTunnelHint = serverState.listenAll ? null : buildStudioSshTunnelHint(serverState.port, url);
19200
19231
  if (sshTunnelHint) ctx.ui.notify(sshTunnelHint, "info");
19232
+ if (serverState.listenAll) ctx.ui.notify(buildStudioListenAllWarning(serverState.port, url), "warning");
19201
19233
  return;
19202
19234
  }
19203
19235
 
@@ -19210,7 +19242,8 @@ export default function (pi: ExtensionAPI) {
19210
19242
  + " /studio --blank Open with blank editor\n"
19211
19243
  + " /studio --last Open with last model response\n"
19212
19244
  + " /studio --no-browser Print the Studio URL without opening a browser\n"
19213
- + " /studio --port <port> Bind Studio to a fixed localhost port when starting\n"
19245
+ + " /studio --port <port> Bind Studio to a fixed port when starting\n"
19246
+ + " /studio --listen-all Explicitly bind to 0.0.0.0 instead of localhost (security-sensitive)\n"
19214
19247
  + " /studio --open-remote Over SSH, open the remote browser anyway\n"
19215
19248
  + " /studio --status Show studio status\n"
19216
19249
  + " /studio --stop Stop studio server\n"
@@ -19241,7 +19274,8 @@ export default function (pi: ExtensionAPI) {
19241
19274
  + " /studio-replace --blank Replace with blank editor\n"
19242
19275
  + " /studio-replace --last Replace with last model response\n"
19243
19276
  + " /studio-replace --no-browser Print URL without opening a browser\n"
19244
- + " /studio-replace --port <port> Bind Studio to a fixed localhost port when starting\n"
19277
+ + " /studio-replace --port <port> Bind Studio to a fixed port when starting\n"
19278
+ + " /studio-replace --listen-all Explicitly bind to 0.0.0.0 instead of localhost\n"
19245
19279
  + "Editor-only Studio views stay open.",
19246
19280
  "info",
19247
19281
  );
@@ -19269,7 +19303,8 @@ export default function (pi: ExtensionAPI) {
19269
19303
  + " /studio-editor-only --blank Open with blank editor\n"
19270
19304
  + " /studio-editor-only --last Open with last model response loaded into the editor\n"
19271
19305
  + " /studio-editor-only --no-browser Print URL without opening a browser\n"
19272
- + " /studio-editor-only --port <port> Bind Studio to a fixed localhost port when starting\n"
19306
+ + " /studio-editor-only --port <port> Bind Studio to a fixed port when starting\n"
19307
+ + " /studio-editor-only --listen-all Explicitly bind to 0.0.0.0 instead of localhost\n"
19273
19308
  + "Multiple editor-only views are allowed in the same Pi session.",
19274
19309
  "info",
19275
19310
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.59",
3
+ "version": "0.9.60-rc.0",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,103 @@
1
+ export const STUDIO_LOOPBACK_BIND_HOST = "127.0.0.1";
2
+ export const STUDIO_ALL_INTERFACES_BIND_HOST = "0.0.0.0";
3
+ export const STUDIO_ADVERTISED_HOST = STUDIO_LOOPBACK_BIND_HOST;
4
+
5
+ /**
6
+ * Resolve Studio's server-lifetime network binding. Listening on every IPv4
7
+ * interface is deliberately opt-in; generated browser URLs remain loopback URLs
8
+ * so same-port container publishing does not require URL editing.
9
+ *
10
+ * @param {boolean} [listenAll]
11
+ * @returns {{ bindHost: string, advertisedHost: string, listenAll: boolean }}
12
+ */
13
+ export function resolveStudioNetworkBinding(listenAll = false) {
14
+ const enabled = listenAll === true;
15
+ return {
16
+ bindHost: enabled ? STUDIO_ALL_INTERFACES_BIND_HOST : STUDIO_LOOPBACK_BIND_HOST,
17
+ advertisedHost: STUDIO_ADVERTISED_HOST,
18
+ listenAll: enabled,
19
+ };
20
+ }
21
+
22
+ /**
23
+ * Parse only an HTTP origin-form request target. The inbound Host header is not
24
+ * needed for Studio routing and must not become the base URL authority.
25
+ *
26
+ * @param {string | undefined} requestTarget
27
+ * @returns {URL | null}
28
+ */
29
+ export function parseStudioRequestTarget(requestTarget) {
30
+ const value = typeof requestTarget === "string" ? requestTarget : "/";
31
+ if (!value.startsWith("/") || value.startsWith("//")) return null;
32
+ try {
33
+ const expectedOrigin = `http://${STUDIO_ADVERTISED_HOST}`;
34
+ const parsed = new URL(value, expectedOrigin);
35
+ return parsed.origin === expectedOrigin ? parsed : null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Preserve the existing permissive Origin behavior for the localhost server.
43
+ * In wildcard mode, an omitted Origin remains compatible with embedded and
44
+ * non-browser clients; a supplied browser Origin must be HTTP(S) and match the
45
+ * request's Host authority so a cross-origin page cannot drive the WebSocket.
46
+ *
47
+ * @param {string | undefined} originHeader
48
+ * @param {string | undefined} requestHost
49
+ * @param {boolean} [requireSameHost]
50
+ * @returns {boolean}
51
+ */
52
+ export function isStudioWebSocketOriginAllowed(originHeader, requestHost, requireSameHost = false) {
53
+ if (!originHeader || !requireSameHost) return true;
54
+ let originUrl;
55
+ try {
56
+ originUrl = new URL(originHeader);
57
+ } catch {
58
+ return false;
59
+ }
60
+ if (originUrl.protocol !== "http:" && originUrl.protocol !== "https:") return false;
61
+ if (
62
+ originUrl.username
63
+ || originUrl.password
64
+ || originUrl.pathname !== "/"
65
+ || originUrl.search
66
+ || originUrl.hash
67
+ ) return false;
68
+ if (typeof requestHost !== "string" || requestHost.length === 0) return false;
69
+
70
+ try {
71
+ const requestOrigin = new URL(`http://${requestHost}`);
72
+ if (
73
+ requestOrigin.username
74
+ || requestOrigin.password
75
+ || requestOrigin.pathname !== "/"
76
+ || requestOrigin.search
77
+ || requestOrigin.hash
78
+ ) return false;
79
+ return originUrl.host.toLowerCase() === requestOrigin.host.toLowerCase();
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * @param {number} port
87
+ * @param {string} [studioUrl]
88
+ * @returns {string}
89
+ */
90
+ export function buildStudioListenAllWarning(port, studioUrl) {
91
+ const normalizedPort = Number.isInteger(port) && port > 0 && port <= 65535
92
+ ? port
93
+ : "<port>";
94
+ const urlLine = typeof studioUrl === "string" && studioUrl.length > 0
95
+ ? `\nStudio URL: ${studioUrl}`
96
+ : "";
97
+ return "Security warning: pi Studio is listening on all IPv4 interfaces at "
98
+ + `${STUDIO_ALL_INTERFACES_BIND_HOST}:${normalizedPort}. `
99
+ + "Anyone who obtains the tokenized Studio URL can control Studio for this Pi process, including submitting prompts, reading or writing files through Studio, and expanding local-resource access. "
100
+ + "Treat the URL like a password. Expose this port only through a trusted local mapping or private network, and prefer SSH tunnelling on untrusted networks. "
101
+ + "Use /studio --stop before restarting without --listen-all to return to localhost-only mode."
102
+ + urlLine;
103
+ }