| 1 | import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | |
| 5 | import type { McpProviderSessionConfig } from "@t3tools/provider-core/server/mcpSession"; |
| 6 | import { |
| 7 | PI_T3_MCP_EXTENSION_FILENAME, |
| 8 | PI_T3_MCP_EXTENSION_SOURCE, |
| 9 | T3_MCP_BEARER_ENV, |
| 10 | T3_MCP_URL_ENV, |
| 11 | T3_PI_RUNTIME_MODE_ENV, |
| 12 | T3_PI_MCP_EXTENSION_PATH_ENV, |
| 13 | } from "./mcpExtensionSource.ts"; |
| 14 | |
| 15 | const RESERVED_PI_LAUNCH_ARGUMENTS = new Set([ |
| 16 | "--continue", |
| 17 | "-c", |
| 18 | "--export", |
| 19 | "--fork", |
| 20 | "--help", |
| 21 | "-h", |
| 22 | "--list-models", |
| 23 | "--mode", |
| 24 | "--no-session", |
| 25 | "--print", |
| 26 | "-p", |
| 27 | "--resume", |
| 28 | "-r", |
| 29 | "--session", |
| 30 | "--session-id", |
| 31 | "--version", |
| 32 | "-v", |
| 33 | ]); |
| 34 | |
| 35 | const PI_ARGUMENTS_WITH_VALUES = new Set([ |
| 36 | "--api-key", |
| 37 | "--append-system-prompt", |
| 38 | "--exclude-tools", |
| 39 | "-xt", |
| 40 | "--extension", |
| 41 | "-e", |
| 42 | "--model", |
| 43 | "--models", |
| 44 | "--name", |
| 45 | "-n", |
| 46 | "--prompt-template", |
| 47 | "--provider", |
| 48 | "--session-dir", |
| 49 | "--skill", |
| 50 | "--system-prompt", |
| 51 | "--theme", |
| 52 | "--thinking", |
| 53 | "--tools", |
| 54 | "-t", |
| 55 | "--tui-mode", |
| 56 | "--use-theme", |
| 57 | ]); |
| 58 | |
| 59 | const PI_ARGUMENTS_WITHOUT_VALUES = new Set([ |
| 60 | "--approve", |
| 61 | "-a", |
| 62 | "--no-approve", |
| 63 | "-na", |
| 64 | "--no-builtin-tools", |
| 65 | "-nbt", |
| 66 | "--no-context-files", |
| 67 | "-nc", |
| 68 | "--no-extensions", |
| 69 | "-ne", |
| 70 | "--no-prompt-templates", |
| 71 | "-np", |
| 72 | "--no-skills", |
| 73 | "-ns", |
| 74 | "--no-themes", |
| 75 | "--no-tools", |
| 76 | "-nt", |
| 77 | "--offline", |
| 78 | "--verbose", |
| 79 | ]); |
| 80 | |
| 81 | export type PiLaunchArgsResolution = |
| 82 | | { readonly ok: true; readonly args: ReadonlyArray<string> } |
| 83 | | { readonly ok: false; readonly message: string }; |
| 84 | |
| 85 | function reservedPiArgument(arg: string): string | undefined { |
| 86 | for (const reserved of RESERVED_PI_LAUNCH_ARGUMENTS) { |
| 87 | if (arg === reserved || (reserved.startsWith("--") && arg.startsWith(`${reserved}=`))) { |
| 88 | return reserved; |
| 89 | } |
| 90 | } |
| 91 | return undefined; |
| 92 | } |
| 93 | |
| 94 | function normalizePiBuiltInEqualsArguments(args: ReadonlyArray<string>): ReadonlyArray<string> { |
| 95 | return args.flatMap((arg) => { |
| 96 | const equalsIndex = arg.indexOf("="); |
| 97 | if (equalsIndex <= 0) return [arg]; |
| 98 | const option = arg.slice(0, equalsIndex); |
| 99 | return PI_ARGUMENTS_WITH_VALUES.has(option) ? [option, arg.slice(equalsIndex + 1)] : [arg]; |
| 100 | }); |
| 101 | } |
| 102 | |
| 103 | /** |
| 104 | * Pi launch arguments may configure resources, models, tools, trust, and |
| 105 | * storage. T3 owns RPC mode and session identity, so arguments that select a |
| 106 | * different execution mode or native session are rejected before spawn. |
| 107 | */ |
| 108 | export function resolvePiLaunchArgs(launchArgs: string): PiLaunchArgsResolution { |
| 109 | // Pi parses equals-form tokens only as extension flags, even when their name |
| 110 | // matches a built-in option. Split known built-ins while leaving arbitrary |
| 111 | // extension flags in their native form. |
| 112 | const args = normalizePiBuiltInEqualsArguments(tokenizeCliArgs(launchArgs)); |
| 113 | let hasProvider = false; |
| 114 | let hasModel = false; |
| 115 | for (let index = 0; index < args.length; index += 1) { |
| 116 | const arg = args[index]; |
| 117 | if (arg === undefined) continue; |
| 118 | const reserved = reservedPiArgument(arg); |
| 119 | if (reserved !== undefined) { |
| 120 | return { |
| 121 | ok: false, |
| 122 | message: `Pi launch argument '${reserved}' is controlled by T3 Code and cannot be overridden.`, |
| 123 | }; |
| 124 | } |
| 125 | if (arg === "--") { |
| 126 | return { |
| 127 | ok: false, |
| 128 | message: "Pi launch arguments cannot include positional prompts.", |
| 129 | }; |
| 130 | } |
| 131 | if (PI_ARGUMENTS_WITH_VALUES.has(arg)) { |
| 132 | const value = args[index + 1]; |
| 133 | if (value === undefined) { |
| 134 | return { ok: false, message: `Pi launch argument '${arg}' requires a value.` }; |
| 135 | } |
| 136 | if (arg === "--provider") hasProvider = true; |
| 137 | if (arg === "--model") hasModel = true; |
| 138 | index += 1; |
| 139 | continue; |
| 140 | } |
| 141 | if (PI_ARGUMENTS_WITHOUT_VALUES.has(arg) || (arg.startsWith("--") && arg.includes("="))) { |
| 142 | continue; |
| 143 | } |
| 144 | if (arg.startsWith("--")) { |
| 145 | // Pi extensions may register arbitrary long flags. Treat one following |
| 146 | // non-flag token as that extension flag's value. |
| 147 | if ( |
| 148 | args[index + 1] !== undefined && |
| 149 | !args[index + 1]!.startsWith("-") && |
| 150 | !args[index + 1]!.startsWith("@") |
| 151 | ) { |
| 152 | index += 1; |
| 153 | } |
| 154 | continue; |
| 155 | } |
| 156 | if (arg.startsWith("-")) { |
| 157 | return { ok: false, message: `Pi launch argument '${arg}' is not supported by T3 Code.` }; |
| 158 | } |
| 159 | return { |
| 160 | ok: false, |
| 161 | message: `Pi launch arguments cannot include positional prompt '${arg}'.`, |
| 162 | }; |
| 163 | } |
| 164 | // Pi 1.0 exits at startup on `--provider` without `--model`; older versions |
| 165 | // silently ran another provider's default model. Rejecting it here makes |
| 166 | // every spawn site report the reason instead of a bare exit code. |
| 167 | if (hasProvider && !hasModel) { |
| 168 | return { ok: false, message: "Pi launch argument '--provider' requires '--model'." }; |
| 169 | } |
| 170 | return { ok: true, args }; |
| 171 | } |
| 172 | |
| 173 | function bearerTokenFromAuthorizationHeader(header: string): string { |
| 174 | return header.startsWith("Bearer ") ? header.slice("Bearer ".length) : header; |
| 175 | } |
| 176 | |
| 177 | function normalizedPiPath(value: string): string { |
| 178 | return value.replace(/\\/g, "/").replace(/\/+$/, ""); |
| 179 | } |
| 180 | |
| 181 | function hasExplicitExtension(args: ReadonlyArray<string>, extensionPath: string): boolean { |
| 182 | const wanted = normalizedPiPath(extensionPath); |
| 183 | for (let index = 0; index < args.length; index += 1) { |
| 184 | const arg = args[index]; |
| 185 | if (arg !== "--extension" && arg !== "-e") continue; |
| 186 | const configured = args[index + 1]; |
| 187 | if (configured !== undefined && normalizedPiPath(configured) === wanted) return true; |
| 188 | index += 1; |
| 189 | } |
| 190 | return false; |
| 191 | } |
| 192 | |
| 193 | function withoutExplicitExtensions(args: ReadonlyArray<string>): ReadonlyArray<string> { |
| 194 | const filtered: string[] = []; |
| 195 | for (let index = 0; index < args.length; index += 1) { |
| 196 | const arg = args[index]; |
| 197 | if (arg === undefined) continue; |
| 198 | if (arg === "--extension" || arg === "-e") { |
| 199 | index += 1; |
| 200 | continue; |
| 201 | } |
| 202 | if (arg.startsWith("--extension=") || arg.startsWith("-e=")) continue; |
| 203 | filtered.push(arg); |
| 204 | } |
| 205 | return filtered; |
| 206 | } |
| 207 | |
| 208 | function withoutToolSelectionArgs(args: ReadonlyArray<string>): ReadonlyArray<string> { |
| 209 | const filtered: string[] = []; |
| 210 | for (let index = 0; index < args.length; index += 1) { |
| 211 | const arg = args[index]; |
| 212 | if (arg === undefined) continue; |
| 213 | if (arg === "--tools" || arg === "-t" || arg === "--exclude-tools" || arg === "-xt") { |
| 214 | index += 1; |
| 215 | continue; |
| 216 | } |
| 217 | if ( |
| 218 | arg === "--no-tools" || |
| 219 | arg === "-nt" || |
| 220 | arg === "--no-builtin-tools" || |
| 221 | arg === "-nbt" || |
| 222 | arg.startsWith("--tools=") || |
| 223 | arg.startsWith("-t=") || |
| 224 | arg.startsWith("--exclude-tools=") || |
| 225 | arg.startsWith("-xt=") |
| 226 | ) { |
| 227 | continue; |
| 228 | } |
| 229 | filtered.push(arg); |
| 230 | } |
| 231 | return filtered; |
| 232 | } |
| 233 | |
| 234 | function piT3McpExtensionDestPath(cacheDir: string): string { |
| 235 | return `${cacheDir.replace(/\\/g, "/")}/${PI_T3_MCP_EXTENSION_FILENAME}`; |
| 236 | } |
| 237 | |
| 238 | export const materializePiT3McpExtension = Effect.fn("materializePiT3McpExtension")(function* ( |
| 239 | cacheDir: string, |
| 240 | ) { |
| 241 | const fs = yield* FileSystem.FileSystem; |
| 242 | yield* fs.makeDirectory(cacheDir, { recursive: true }); |
| 243 | const dest = piT3McpExtensionDestPath(cacheDir); |
| 244 | const existing = yield* fs.readFileString(dest).pipe(Effect.orElseSucceed(() => "")); |
| 245 | if (existing !== PI_T3_MCP_EXTENSION_SOURCE) { |
| 246 | yield* fs.writeFileString(dest, PI_T3_MCP_EXTENSION_SOURCE); |
| 247 | } |
| 248 | return dest; |
| 249 | }); |
| 250 | |
| 251 | export function buildPiRpcLaunch(input: { |
| 252 | readonly launchArgs: ReadonlyArray<string>; |
| 253 | readonly environment: NodeJS.ProcessEnv; |
| 254 | readonly mcpSession: McpProviderSessionConfig | undefined; |
| 255 | readonly extensionPath: string | undefined; |
| 256 | readonly ephemeral?: boolean; |
| 257 | readonly disableExtensions?: boolean; |
| 258 | readonly disableTools?: boolean; |
| 259 | readonly runtimeMode?: "approval-required" | "auto-accept-edits" | "auto" | "full-access"; |
| 260 | }): { |
| 261 | readonly args: ReadonlyArray<string>; |
| 262 | readonly env: NodeJS.ProcessEnv; |
| 263 | readonly hasT3Mcp: boolean; |
| 264 | } { |
| 265 | const hasT3Extension = input.disableExtensions !== true && input.extensionPath !== undefined; |
| 266 | const hasT3Mcp = hasT3Extension && input.mcpSession !== undefined; |
| 267 | const extensionSafeArgs = |
| 268 | input.disableExtensions === true |
| 269 | ? withoutExplicitExtensions(input.launchArgs) |
| 270 | : input.launchArgs; |
| 271 | const launchArgs = |
| 272 | input.disableTools === true ? withoutToolSelectionArgs(extensionSafeArgs) : extensionSafeArgs; |
| 273 | const args = [ |
| 274 | "--mode", |
| 275 | "rpc", |
| 276 | ...(input.ephemeral === true ? ["--no-session"] : []), |
| 277 | ...launchArgs, |
| 278 | // Restrictions follow user launch args so a configured --tools or |
| 279 | // --extension cannot silently re-enable unattended text-generation code. |
| 280 | ...(input.disableExtensions === true ? ["--no-extensions"] : []), |
| 281 | ...(input.disableTools === true ? ["--no-tools"] : []), |
| 282 | ]; |
| 283 | if ( |
| 284 | hasT3Extension && |
| 285 | input.extensionPath !== undefined && |
| 286 | !hasExplicitExtension(args, input.extensionPath) |
| 287 | ) { |
| 288 | args.push("--extension", input.extensionPath); |
| 289 | } |
| 290 | const environment = { ...input.environment }; |
| 291 | // These values belong to the current T3 session. Never let a Pi child reuse |
| 292 | // credentials inherited from the server or a parent provider process. |
| 293 | delete environment[T3_MCP_URL_ENV]; |
| 294 | delete environment[T3_MCP_BEARER_ENV]; |
| 295 | delete environment[T3_PI_MCP_EXTENSION_PATH_ENV]; |
| 296 | |
| 297 | return { |
| 298 | args, |
| 299 | env: { |
| 300 | ...environment, |
| 301 | ...(hasT3Extension && input.extensionPath !== undefined |
| 302 | ? { [T3_PI_MCP_EXTENSION_PATH_ENV]: input.extensionPath } |
| 303 | : {}), |
| 304 | ...(hasT3Extension && input.runtimeMode !== undefined |
| 305 | ? { |
| 306 | [T3_PI_RUNTIME_MODE_ENV]: |
| 307 | input.runtimeMode === "auto" ? "approval-required" : input.runtimeMode, |
| 308 | } |
| 309 | : {}), |
| 310 | ...(hasT3Mcp && input.mcpSession !== undefined |
| 311 | ? { |
| 312 | [T3_MCP_URL_ENV]: input.mcpSession.endpoint, |
| 313 | [T3_MCP_BEARER_ENV]: bearerTokenFromAuthorizationHeader( |
| 314 | input.mcpSession.authorizationHeader, |
| 315 | ), |
| 316 | } |
| 317 | : {}), |
| 318 | }, |
| 319 | hasT3Mcp, |
| 320 | }; |
| 321 | } |