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 +8 -0
- package/README.md +16 -3
- package/ROADMAP.md +11 -0
- package/index.ts +72 -37
- package/package.json +1 -1
- package/shared/studio-network-binding.js +103 -0
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
|
|
87
|
-
| `/studio --
|
|
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
|
-
-
|
|
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
|
|
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
|
-
|
|
17450
|
-
|
|
17451
|
-
|
|
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
|
|
18066
|
-
|
|
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
|
-
|
|
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
|
|
18094
|
-
|
|
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,
|
|
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 =
|
|
19086
|
-
|
|
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 =
|
|
19134
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
}
|