byob-cli 0.2.9 → 0.2.11

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "byob-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.11",
4
4
  "description": "Codex connector for BYOB projects, backed by Agent MCP with project context, deployment helpers, and coding tools.",
5
5
  "skills": "./skills/byob_cli/",
6
6
  "mcpServers": "./.mcp.json"
package/README.md CHANGED
@@ -30,6 +30,14 @@ Refresh synced coding skills later without rerunning OAuth or changing MCP confi
30
30
  npx -y byob-cli codex sync-skills --project-id <project_id> --token <agent_access_token>
31
31
  ```
32
32
 
33
+ For Local Codex billed project editing, the BYOB Agent MCP opens the project container as the active Codex workspace. It is powered by `codex ssh-proxy`:
34
+
35
+ ```bash
36
+ npx -y byob-cli@latest codex ssh-proxy '{"ssh_endpoint":"wss://..."}'
37
+ ```
38
+
39
+ Use `byob-cli@latest` for Local Codex billed.
40
+
33
41
  Or install it globally:
34
42
 
35
43
  ```bash
@@ -193,6 +201,27 @@ npx -y byob-cli codex sync-skills --project-id <project_id> --token <agent_acces
193
201
 
194
202
  `codex sync-skills` downloads AIR coding-skill resources and rewrites their local skill names with `byob_` prefixes. It does not run OAuth, does not call `codex mcp add`, and does not change the active MCP server config.
195
203
 
204
+ ### Local Codex Billed
205
+
206
+ When a user chooses **Local Codex billed**, open the BYOB project as the active Codex workspace from the start of the edit session:
207
+
208
+ 1. Call `byob_codex_status` for the selected project.
209
+ 2. If `Local Codex billed` is available, call `byob_codex_connection_create` with `connection_type: "ssh_gateway"`.
210
+ 3. Hand the returned workspace metadata to the Codex host so `/home/workspace/app_data` becomes the active workspace.
211
+ 4. Keep tokens, private keys, and complete proxy commands out of chat.
212
+ 5. Use Codex's normal file, shell, and git workflow inside that active workspace.
213
+
214
+ The underlying proxy command bridges OpenSSH stdio to AIR's authenticated WebSocket endpoint:
215
+
216
+ ```bash
217
+ BYOB_CODEX_SSH_TOKEN=<connection_token> \
218
+ ssh -i <temporary_key_file> \
219
+ -o ProxyCommand="npx -y byob-cli@latest codex ssh-proxy '{\"ssh_endpoint\":\"<ssh_endpoint>\"}'" \
220
+ byob@byob-project
221
+ ```
222
+
223
+ Agents and hosts should keep this user-facing: say "Local Codex billed", not internal labels such as `ssh_gateway`, unless debugging the connector itself.
224
+
196
225
  ## Multiple Projects
197
226
 
198
227
  BYOB grants can cover more than one approved project. There are two supported install shapes.
package/bin/byob.js CHANGED
@@ -3,6 +3,9 @@
3
3
  import { createInterface } from "node:readline";
4
4
  import { spawn, spawnSync } from "node:child_process";
5
5
  import { copyFileSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
6
+ import { createHash, randomBytes } from "node:crypto";
7
+ import net from "node:net";
8
+ import tls from "node:tls";
6
9
  import { dirname, join } from "node:path";
7
10
  import { fileURLToPath } from "node:url";
8
11
  import { homedir } from "node:os";
@@ -38,6 +41,7 @@ Usage:
38
41
  byob config
39
42
  byob codex install
40
43
  byob codex sync-skills
44
+ byob codex ssh-proxy [json_args]
41
45
 
42
46
  Options:
43
47
  --air-url <url> Default: BYOB_AIR_URL or https://air.api.byob.studio
@@ -191,12 +195,49 @@ async function mcpRequest(opts, method, params = {}, requestId = Date.now(), pro
191
195
  });
192
196
  }
193
197
 
194
- function printJson(payload) {
195
- process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
198
+ function publicPreviewUrl(projectId) {
199
+ const id = String(projectId || "").trim();
200
+ return id ? `https://preview.${id}.api.byob.studio` : "";
196
201
  }
197
202
 
198
- function printMcpResponse(payload) {
199
- process.stdout.write(`${JSON.stringify(payload)}\n`);
203
+ function sanitizePreviewUrls(value, projectIdHint = "", { sanitizeText = false } = {}) {
204
+ if (Array.isArray(value)) {
205
+ return value.map((item) => sanitizePreviewUrls(item, projectIdHint, { sanitizeText }));
206
+ }
207
+ if (!value || typeof value !== "object") {
208
+ if (sanitizeText && typeof value === "string") {
209
+ try {
210
+ const parsed = JSON.parse(value);
211
+ return JSON.stringify(sanitizePreviewUrls(parsed, projectIdHint, { sanitizeText }), null, 2);
212
+ } catch {
213
+ return value;
214
+ }
215
+ }
216
+ return value;
217
+ }
218
+
219
+ const localProjectId = value.project_id || value.id || projectIdHint;
220
+ const sanitized = {};
221
+ for (const [key, item] of Object.entries(value)) {
222
+ if (key === "preview_url") {
223
+ sanitized[key] = publicPreviewUrl(localProjectId) || item;
224
+ continue;
225
+ }
226
+ sanitized[key] = sanitizePreviewUrls(item, localProjectId, {
227
+ sanitizeText: sanitizeText && key === "text",
228
+ });
229
+ }
230
+ return sanitized;
231
+ }
232
+
233
+ function printJson(payload, projectIdHint = "", sanitizeText = false) {
234
+ const safePayload = sanitizePreviewUrls(payload, projectIdHint, { sanitizeText });
235
+ process.stdout.write(`${JSON.stringify(safePayload, null, 2)}\n`);
236
+ }
237
+
238
+ function printMcpResponse(payload, projectIdHint = "", sanitizeText = false) {
239
+ const safePayload = sanitizePreviewUrls(payload, projectIdHint, { sanitizeText });
240
+ process.stdout.write(`${JSON.stringify(safePayload)}\n`);
200
241
  }
201
242
 
202
243
  function byobBanner() {
@@ -288,7 +329,8 @@ async function serve(opts) {
288
329
  timeoutSeconds: opts.timeout,
289
330
  body: request,
290
331
  });
291
- printMcpResponse(response);
332
+ const toolName = request?.method === "tools/call" ? request?.params?.name || "" : "";
333
+ printMcpResponse(response, projectId, String(toolName).startsWith("byob_"));
292
334
  } catch (error) {
293
335
  printMcpResponse({
294
336
  jsonrpc: "2.0",
@@ -645,7 +687,7 @@ async function callTool(opts, toolName, rawArgs) {
645
687
  name: toolName,
646
688
  arguments: toolArgs,
647
689
  }, Date.now(), projectId);
648
- printJson(response);
690
+ printJson(response, projectId, toolName.startsWith("byob_"));
649
691
  }
650
692
 
651
693
  async function aliasCall(opts, alias, rawArgs) {
@@ -668,8 +710,9 @@ async function projects(opts) {
668
710
  }
669
711
 
670
712
  async function resources(opts) {
671
- const response = await mcpRequest(opts, "resources/list");
672
- printJson(response);
713
+ const projectId = requireProject(opts);
714
+ const response = await mcpRequest(opts, "resources/list", {}, Date.now(), projectId);
715
+ printJson(response, projectId, true);
673
716
  }
674
717
 
675
718
  async function context(opts, rawArgs) {
@@ -679,7 +722,7 @@ async function context(opts, rawArgs) {
679
722
  name: "byob_current_project_context",
680
723
  arguments: { project_id: projectId },
681
724
  }, Date.now(), projectId);
682
- printJson(response);
725
+ printJson(response, projectId, true);
683
726
  }
684
727
 
685
728
  async function contextResource(opts, rawArgs) {
@@ -693,7 +736,180 @@ async function contextResource(opts, rawArgs) {
693
736
  Date.now(),
694
737
  projectId,
695
738
  );
696
- printJson(response);
739
+ printJson(response, projectId, true);
740
+ }
741
+
742
+ function truthy(value) {
743
+ return ["1", "true", "yes", "on"].includes(String(value || "").trim().toLowerCase());
744
+ }
745
+
746
+ function websocketFrame(opcode, payload = Buffer.alloc(0)) {
747
+ const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
748
+ let headerLength = 2;
749
+ if (body.length >= 126 && body.length <= 65535) headerLength += 2;
750
+ else if (body.length > 65535) headerLength += 8;
751
+ const mask = randomBytes(4);
752
+ const frame = Buffer.alloc(headerLength + 4 + body.length);
753
+ frame[0] = 0x80 | opcode;
754
+ if (body.length < 126) {
755
+ frame[1] = 0x80 | body.length;
756
+ } else if (body.length <= 65535) {
757
+ frame[1] = 0x80 | 126;
758
+ frame.writeUInt16BE(body.length, 2);
759
+ } else {
760
+ frame[1] = 0x80 | 127;
761
+ frame.writeBigUInt64BE(BigInt(body.length), 2);
762
+ }
763
+ mask.copy(frame, headerLength);
764
+ for (let i = 0; i < body.length; i += 1) {
765
+ frame[headerLength + 4 + i] = body[i] ^ mask[i % 4];
766
+ }
767
+ return frame;
768
+ }
769
+
770
+ function parseWebSocketFrames(buffer, onPayload) {
771
+ let offset = 0;
772
+ while (buffer.length - offset >= 2) {
773
+ const first = buffer[offset];
774
+ const second = buffer[offset + 1];
775
+ const opcode = first & 0x0f;
776
+ const masked = Boolean(second & 0x80);
777
+ let length = second & 0x7f;
778
+ let headerLength = 2;
779
+ if (length === 126) {
780
+ if (buffer.length - offset < 4) break;
781
+ length = buffer.readUInt16BE(offset + 2);
782
+ headerLength = 4;
783
+ } else if (length === 127) {
784
+ if (buffer.length - offset < 10) break;
785
+ const bigLength = buffer.readBigUInt64BE(offset + 2);
786
+ if (bigLength > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("WebSocket frame too large.");
787
+ length = Number(bigLength);
788
+ headerLength = 10;
789
+ }
790
+ const maskLength = masked ? 4 : 0;
791
+ if (buffer.length - offset < headerLength + maskLength + length) break;
792
+ const mask = masked ? buffer.subarray(offset + headerLength, offset + headerLength + 4) : null;
793
+ const payloadStart = offset + headerLength + maskLength;
794
+ const payload = Buffer.from(buffer.subarray(payloadStart, payloadStart + length));
795
+ if (mask) {
796
+ for (let i = 0; i < payload.length; i += 1) payload[i] ^= mask[i % 4];
797
+ }
798
+ if (opcode === 0x8) return { remaining: Buffer.alloc(0), closed: true };
799
+ if (opcode === 0x9) onPayload(null, websocketFrame(0xA, payload));
800
+ else if (opcode === 0x1 || opcode === 0x2 || opcode === 0x0) onPayload(payload, null);
801
+ offset = payloadStart + length;
802
+ }
803
+ return { remaining: buffer.subarray(offset), closed: false };
804
+ }
805
+
806
+ async function websocketToStdio(endpoint, token, options = {}) {
807
+ const url = new URL(endpoint);
808
+ const isTls = url.protocol === "wss:";
809
+ if (!["ws:", "wss:"].includes(url.protocol)) {
810
+ throw new Error("SSH endpoint must be a ws:// or wss:// URL.");
811
+ }
812
+ const port = Number(url.port || (isTls ? 443 : 80));
813
+ const tlsCaCert = options.tlsCaCert || process.env.BYOB_CODEX_SSH_CA_CERT || "";
814
+ const tlsInsecure = Boolean(options.tlsInsecure) || truthy(process.env.BYOB_CODEX_SSH_TLS_INSECURE);
815
+ const tlsOptions = {};
816
+ if (isTls) {
817
+ if (tlsCaCert) tlsOptions.ca = readFileSync(tlsCaCert, "utf8");
818
+ if (tlsInsecure) tlsOptions.rejectUnauthorized = false;
819
+ }
820
+ const socket = isTls
821
+ ? tls.connect({ host: url.hostname, port, servername: url.hostname, ...tlsOptions })
822
+ : net.connect({ host: url.hostname, port });
823
+
824
+ await new Promise((resolve, reject) => {
825
+ socket.once(isTls ? "secureConnect" : "connect", resolve);
826
+ socket.once("error", reject);
827
+ });
828
+
829
+ const key = randomBytes(16).toString("base64");
830
+ const path = `${url.pathname || "/"}${url.search || ""}`;
831
+ socket.write(
832
+ [
833
+ `GET ${path} HTTP/1.1`,
834
+ `Host: ${url.host}`,
835
+ "Upgrade: websocket",
836
+ "Connection: Upgrade",
837
+ `Sec-WebSocket-Key: ${key}`,
838
+ "Sec-WebSocket-Version: 13",
839
+ token ? `Authorization: Bearer ${token}` : "",
840
+ "",
841
+ "",
842
+ ].filter((line) => line !== null).join("\r\n"),
843
+ );
844
+
845
+ await new Promise((resolve, reject) => {
846
+ let handshake = Buffer.alloc(0);
847
+ function onData(chunk) {
848
+ handshake = Buffer.concat([handshake, chunk]);
849
+ const end = handshake.indexOf("\r\n\r\n");
850
+ if (end === -1) return;
851
+ socket.off("data", onData);
852
+ const headerText = handshake.subarray(0, end).toString("utf8");
853
+ if (!/^HTTP\/1\.1 101\b/i.test(headerText)) {
854
+ reject(new Error(`WebSocket upgrade failed: ${headerText.split(/\r?\n/)[0] || "unknown response"}`));
855
+ return;
856
+ }
857
+ const acceptHeader = headerText
858
+ .split(/\r?\n/)
859
+ .find((line) => /^sec-websocket-accept:/i.test(line))
860
+ ?.split(":")
861
+ .slice(1)
862
+ .join(":")
863
+ .trim();
864
+ const expectedAccept = createHash("sha1")
865
+ .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
866
+ .digest("base64");
867
+ if (acceptHeader !== expectedAccept) {
868
+ reject(new Error("WebSocket upgrade failed: invalid Sec-WebSocket-Accept header"));
869
+ return;
870
+ }
871
+ const leftover = handshake.subarray(end + 4);
872
+ if (leftover.length) socket.emit("byob-leftover", leftover);
873
+ resolve();
874
+ }
875
+ socket.on("data", onData);
876
+ socket.once("error", reject);
877
+ });
878
+
879
+ let frameBuffer = Buffer.alloc(0);
880
+ const handleChunk = (chunk) => {
881
+ frameBuffer = Buffer.concat([frameBuffer, chunk]);
882
+ const parsed = parseWebSocketFrames(frameBuffer, (payload, controlFrame) => {
883
+ if (controlFrame) socket.write(controlFrame);
884
+ if (payload) process.stdout.write(payload);
885
+ });
886
+ frameBuffer = parsed.remaining;
887
+ if (parsed.closed) process.exit(0);
888
+ };
889
+ socket.on("byob-leftover", handleChunk);
890
+ socket.on("data", handleChunk);
891
+ socket.on("close", () => process.exit(0));
892
+ socket.on("error", (error) => {
893
+ process.stderr.write(`${error.message}\n`);
894
+ process.exit(1);
895
+ });
896
+
897
+ process.stdin.on("data", (chunk) => socket.write(websocketFrame(0x2, chunk)));
898
+ process.stdin.on("end", () => socket.write(websocketFrame(0x8)));
899
+ process.stdin.resume();
900
+ await new Promise((resolve) => socket.once("close", resolve));
901
+ }
902
+
903
+ async function codexSshProxy(_opts, rawArgs) {
904
+ const args = parseJsonArg(rawArgs);
905
+ const endpoint = args.ssh_endpoint || process.env.BYOB_CODEX_SSH_ENDPOINT || "";
906
+ const tokenEnvVar = args.token_env_var || "BYOB_CODEX_SSH_TOKEN";
907
+ const token = args.token || process.env[tokenEnvVar] || process.env.BYOB_CODEX_SSH_TOKEN || "";
908
+ const tlsCaCert = args.tls_ca_cert || args.ca_cert || process.env.BYOB_CODEX_SSH_CA_CERT || "";
909
+ const tlsInsecure = truthy(args.tls_insecure) || truthy(args.insecure_tls);
910
+ if (!endpoint) throw new Error("Missing SSH endpoint. Pass {\"ssh_endpoint\":\"wss://...\"} or set BYOB_CODEX_SSH_ENDPOINT.");
911
+ if (!token) throw new Error(`Missing SSH token. Set ${tokenEnvVar}.`);
912
+ await websocketToStdio(endpoint, token, { tlsCaCert, tlsInsecure });
697
913
  }
698
914
 
699
915
  async function main() {
@@ -708,6 +924,7 @@ async function main() {
708
924
  if (command === "mcp") return serve(opts);
709
925
  if (command === "codex" && first === "install") return codexInstall(opts);
710
926
  if (command === "codex" && first === "sync-skills") return codexSyncSkills(opts);
927
+ if (command === "codex" && first === "ssh-proxy") return codexSshProxy(opts, second);
711
928
  if (command === "config") return config(opts);
712
929
  if (command === "device-code") return deviceCode(opts);
713
930
  if (command === "token") return token(opts, first);
@@ -716,8 +933,14 @@ async function main() {
716
933
  if (command === "context-resource") return contextResource(opts, first);
717
934
  if (command === "resources") return resources(opts);
718
935
  if (command === "projects") return projects(opts);
719
- if (command === "initialize") return printJson(await mcpRequest(opts, "initialize"));
720
- if (command === "tools") return printJson(await mcpRequest(opts, "tools/list"));
936
+ if (command === "initialize") {
937
+ const projectId = requireProject(opts);
938
+ return printJson(await mcpRequest(opts, "initialize", {}, Date.now(), projectId), projectId, true);
939
+ }
940
+ if (command === "tools") {
941
+ const projectId = requireProject(opts);
942
+ return printJson(await mcpRequest(opts, "tools/list", {}, Date.now(), projectId), projectId, true);
943
+ }
721
944
  if (command === "call") return callTool(opts, first, second);
722
945
  if (["status", "start", "deploy", "billing", "credits", "payment-url", "env-status", "grants", "dns"].includes(command)) {
723
946
  return aliasCall(opts, command, first);
package/byob-logo.ascii CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "byob-cli",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "Codex connector for BYOB projects, backed by an Agent MCP stdio bridge.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -65,6 +65,20 @@ Use coding/container tools for:
65
65
 
66
66
  Do not add BYOB platform helper tools into user containers. Do not ask for BYOB internal tokens, editor tokens, preview tokens, Git tokens, Cloudflare tokens, service-role keys, or raw MCP credentials.
67
67
 
68
+ ## Local Codex Billed Route
69
+
70
+ When the user selects **Local Codex billed**, open the BYOB project as the active Codex workspace before trying lower-fidelity Agent MCP workarounds. The expected path is:
71
+
72
+ 1. Establish active project context.
73
+ 2. Ensure the project is started and remote editing is available.
74
+ 3. Call `byob_codex_status`.
75
+ 4. Create a Local Codex billed connection with `byob_codex_connection_create`.
76
+ 5. Use the returned Local Codex billed workspace details through `byob-cli@latest`'s `codex ssh-proxy`.
77
+
78
+ Local Codex billed should let Codex inspect and edit the remote project filesystem directly, including protected files such as `src/app.html` when the task requires reading them. Keep all returned tokens, private keys, and complete proxy commands out of chat and logs. Use user-facing language like "Local Codex billed"; avoid internal enum names unless debugging BYOB itself.
79
+
80
+ After the workspace is open, use Codex's normal file, shell, git, and verification workflow inside the remote project.
81
+
68
82
  ## Skill Routing
69
83
 
70
84
  `byob_cli` is the platform/core skill. Load separate synced skills only when the task calls for them. Do not load every skill by default.
@@ -53,6 +53,16 @@ Refresh downloaded coding skills later without rerunning OAuth or changing MCP c
53
53
  npx -y byob-cli codex sync-skills --project-id <project_id> --token <agent_access_token>
54
54
  ```
55
55
 
56
+ For **Local Codex billed** project editing, open the BYOB project as the active Codex workspace immediately instead of trying to simulate shell/file access through Agent MCP tools:
57
+
58
+ 1. Establish project context with `byob_current_project_context`.
59
+ 2. Call `byob_codex_status` and confirm the Local Codex billed option is available.
60
+ 3. Call `byob_codex_connection_create` with the Local Codex billed connection type.
61
+ 4. Use the returned Local Codex billed workspace metadata through `byob-cli@latest` so Codex edits the remote project container as its active workspace.
62
+ 5. Do not print tokens, private keys, or full proxy commands in chat.
63
+
64
+ After the workspace is open, use Codex's normal file, shell, git, and verification workflow inside the remote project.
65
+
56
66
  Equivalent raw PRIM flow:
57
67
 
58
68
  1. Request a device code from PRIM:
@@ -272,6 +282,20 @@ Coding tools are proxied by AIR into the private project container MCP:
272
282
 
273
283
  Do not assume `byob_*` tools exist inside the user container. They are platform tools and belong to Agent MCP only.
274
284
 
285
+ ## Local Codex Billed Editing
286
+
287
+ When the user chooses Local Codex billed, the expected editing path is that the remote BYOB project container becomes the active Codex workspace. This gives Codex normal filesystem semantics for reading `src/app.html`, searching source, editing files, and running project-local shell commands through the local Codex account billing path.
288
+
289
+ Use this flow:
290
+
291
+ 1. Confirm the project is started and `connectivity.can_remote_edit` is true.
292
+ 2. Call `byob_codex_status`.
293
+ 3. If Local Codex billed is available, call `byob_codex_connection_create` for that mode.
294
+ 4. Use the returned Local Codex billed workspace details with `byob-cli@latest`'s `codex ssh-proxy`.
295
+ 5. Keep credentials out of chat, logs, screenshots, and command transcripts.
296
+
297
+ After the workspace is open, use Codex's normal file, shell, git, and verification workflow inside the remote project.
298
+
275
299
  ## Project Scope
276
300
 
277
301
  The user approves specific projects during browser approval. The agent token can only access those project ids.