Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/release-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ jobs:
echo "sha_darwin_x64=$(sha256sum dist/wrapper-darwin-x86_64.tar.gz | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
echo "sha_linux_x64=$(sha256sum dist/wrapper-linux-x86_64.tar.gz | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"

- name: Write checksums file
run: |
cd dist
sha256sum wrapper-darwin-arm64.tar.gz wrapper-darwin-x86_64.tar.gz wrapper-linux-x86_64.tar.gz > checksums.txt
cat checksums.txt

- name: Create or update release
uses: softprops/action-gh-release@v2
with:
Expand All @@ -139,6 +145,7 @@ jobs:
dist/wrapper-darwin-arm64.tar.gz
dist/wrapper-darwin-x86_64.tar.gz
dist/wrapper-linux-x86_64.tar.gz
dist/checksums.txt

update-homebrew:
name: Update Homebrew Tap
Expand Down
2 changes: 1 addition & 1 deletion .oxfmtignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ out
build
coverage
bun.lock
**/convex/_generated/**
**/_generated/**
**/CHANGELOG.md
14 changes: 14 additions & 0 deletions apps/cli/commands/shell-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,14 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise<void> {
trackError("shell-host", err, { scope: "pty" });
});

// PtySession reports spawn failure synchronously via state. Bail out before
// any further setup so a failed spawn cannot hang the host process.
if (session.status === "closed") {
log.error("failed to start shell session", { sessionId });
process.stderr.write("wrapper: failed to start shell session\n");
process.exit(1);
}

let server: LocalServerHandle;
try {
server = startLocalServer({
Expand Down Expand Up @@ -381,6 +389,12 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise<void> {
});

const exitCode = await new Promise<number | null>((resolve) => {
// Guard against a session that already exited during setup, otherwise the
// late `once("exit")` listener would never fire and the host would hang.
if (session.status === "closed") {
resolve(session.lastExitCode);
return;
}
session.once("exit", (code) => resolve(code));
});

Expand Down
10 changes: 8 additions & 2 deletions apps/cli/pty/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,14 @@ export class PtySession extends EventEmitter<PtySessionEvents> {
});
} catch (err) {
this.state = "closed";
this.emit("error", asError(err, "pty:spawn"));
this.emit("exit", null);
// Defer emits to a microtask: the constructor returns first so callers
// can attach `error`/`exit` listeners. Emitting synchronously here would
// throw on the unlistened `error` event and lose `exit`, hanging the host.
// Callers can also detect failure synchronously via `status === "closed"`.
queueMicrotask(() => {
this.emit("error", asError(err, "pty:spawn"));
this.emit("exit", null);
});
return;
}

Expand Down
33 changes: 30 additions & 3 deletions apps/cli/scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,39 @@ if [ "$VERSION" = "latest" ]; then
fi

archive="wrapper-${platform}.tar.gz"
url="https://github.com/${REPO}/releases/download/${VERSION}/${archive}"
base="https://github.com/${REPO}/releases/download/${VERSION}"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT

echo "Downloading ${url}"
curl -fsSL "$url" -o "${tmp_dir}/${archive}"
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | cut -d' ' -f1
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | cut -d' ' -f1
else
echo "Neither sha256sum nor shasum is available for integrity verification" >&2
exit 1
fi
}

echo "Downloading ${base}/${archive}"
curl -fsSL "${base}/${archive}" -o "${tmp_dir}/${archive}"

echo "Verifying checksum"
curl -fsSL "${base}/checksums.txt" -o "${tmp_dir}/checksums.txt"
expected="$(grep " ${archive}\$" "${tmp_dir}/checksums.txt" | cut -d' ' -f1)"
if [ -z "$expected" ]; then
echo "No checksum found for ${archive} in checksums.txt; aborting" >&2
exit 1
fi
actual="$(sha256_of "${tmp_dir}/${archive}")"
if [ "$expected" != "$actual" ]; then
echo "Checksum mismatch for ${archive}" >&2
echo " expected: ${expected}" >&2
echo " actual: ${actual}" >&2
exit 1
fi

mkdir -p "$INSTALL_DIR"
tar -xzf "${tmp_dir}/${archive}" -C "$INSTALL_DIR"
chmod +x "${INSTALL_DIR}/bin/wrapper" || true
Expand Down
27 changes: 27 additions & 0 deletions apps/cli/tests/pty-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test";
import { PtySession } from "../pty/session";

/**
* Regression guard for the spawn-failure lifecycle (Finding 5): an `exit`
* listener attached *after* construction must still observe termination, and
* the session must never leave a caller awaiting an event that already fired.
* A nonexistent shell makes the PTY helper fail `execvp` and exit non-zero.
*/
describe("PtySession failure lifecycle", () => {
test("late exit listener still observes termination of a failing shell", async () => {
const session = new PtySession({ shell: "/nonexistent/wrapper-bad-shell" });
// Never let an emitted error event throw and crash the test runner.
session.on("error", () => {});

const exitCode = await new Promise<number | null>((resolve) => {
if (session.status === "closed") {
resolve(session.lastExitCode);
return;
}
session.once("exit", (code) => resolve(code));
});

expect(session.status).toBe("closed");
expect(exitCode === null || typeof exitCode === "number").toBe(true);
});
});
1 change: 1 addition & 0 deletions apps/relay/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"hono": "^4.10.6"
},
"devDependencies": {
"@repo/protocol": "*",
"@types/bun": "catalog:",
"oxfmt": "catalog:",
"oxlint": "catalog:"
Expand Down
18 changes: 15 additions & 3 deletions apps/relay/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ type WsData = {

type RelayPayload = string | ArrayBuffer | Uint8Array;

// Cap frames buffered before authorization completes so a misbehaving or
// malicious client cannot use the pre-auth window to exhaust memory.
const MAX_PENDING_FRAMES = 32;

const log = createLogger("relay");
const app = new Hono();
const convexClient = new ConvexHttpClient(resolveConvexUrl());
Expand Down Expand Up @@ -59,7 +63,7 @@ const server = Bun.serve<WsData>({
if (url.pathname === "/ws") {
const ticket = url.searchParams.get("ticket");
const upgraded = serverInstance.upgrade(req, {
data: { ticket },
data: { ticket, authorized: false, pendingMessages: [] },
});
if (upgraded) return undefined;
return new Response("Expected websocket upgrade", { status: 426 });
Expand Down Expand Up @@ -106,6 +110,8 @@ async function authorizeSocket(ws: ServerWebSocket<WsData>): Promise<void> {

try {
const consumed = await convexClient.mutation(consumeTicketRef, { ticket });
// The socket may have closed while the ticket round-trip was in flight.
if (ws.readyState !== WebSocket.OPEN) return;
ws.data.role = consumed.role;
ws.data.sessionId = consumed.sessionId;
ws.data.userId = consumed.userId;
Expand All @@ -119,14 +125,20 @@ async function authorizeSocket(ws: ServerWebSocket<WsData>): Promise<void> {
role: consumed.role,
sessionId: consumed.sessionId,
});
// Flush frames buffered during authorization, in arrival order.
flushPendingMessages(ws);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
log.warn("ticket rejected", { error: err.message });
ws.data.pendingMessages = [];
ws.close(4003, "unauthorized");
}
}

// Authorization is an async Convex round-trip. Frames can arrive before it
// completes (the host bridge sends `session.opened` immediately on open), so
// buffer them and flush in order once the socket is bound, instead of dropping
// the socket as "unbound".
function routeOrQueueMessage(ws: ServerWebSocket<WsData>, raw: unknown): void {
if (!isRelayPayload(raw)) return;

Expand All @@ -136,8 +148,8 @@ function routeOrQueueMessage(ws: ServerWebSocket<WsData>, raw: unknown): void {
}

const pendingMessages = ws.data.pendingMessages ?? [];
if (pendingMessages.length >= 32) {
ws.close(4003, "authorization pending");
if (pendingMessages.length >= MAX_PENDING_FRAMES) {
ws.close(4003, "too many pre-auth messages");
return;
}
pendingMessages.push(raw);
Expand Down
10 changes: 10 additions & 0 deletions apps/relay/src/protocol.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
/*
* Deploy-time copy of `@repo/protocol`.
*
* The relay Docker image installs only its own package (no monorepo
* workspace context), so it cannot resolve `@repo/protocol` at runtime.
* This hand-rolled copy keeps the relay self-contained. Drift is guarded by
* `tests/protocol-parity.test.ts`, which fails CI if this diverges from
* `@repo/protocol`. Keep both in sync when the wire protocol changes.
*/

export type RawWireData = string | ArrayBuffer | Uint8Array;

type SessionId = string;
Expand Down
46 changes: 46 additions & 0 deletions apps/relay/tests/protocol-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test";
import {
WrapperMessageSchema,
encodeMessage as repoEncode,
parseMessage as repoParse,
type WrapperMessage as RepoMessage,
} from "@repo/protocol";
import { encodeMessage as relayEncode, parseMessage as relayParse } from "../src/protocol";

/**
* The relay ships a deploy-time copy of the wire protocol (see
* `src/protocol.ts`). This test fails if that copy drifts from
* `@repo/protocol` — either a new message type is added upstream, or the
* encode/parse behaviour diverges for an existing one.
*/

const samples: RepoMessage[] = [
{ type: "attach", sessionId: "s1" },
{ type: "detach", sessionId: "s1" },
{ type: "input", sessionId: "s1", data: "ls\n" },
{ type: "resize", sessionId: "s1", size: { cols: 80, rows: 24 } },
{ type: "session.opened", sessionId: "s1", size: { cols: 80, rows: 24 } },
{ type: "session.closed", sessionId: "s1", exitCode: 0 },
{ type: "output", sessionId: "s1", data: "hi" },
{ type: "error", sessionId: "s1", code: "internal", message: "boom" },
];

describe("relay protocol parity with @repo/protocol", () => {
test("relay handles every message type @repo/protocol defines", () => {
const repoTypes = new Set(
WrapperMessageSchema.options.map((option) => option.shape.type.value as string),
);
const sampleTypes = new Set<string>(samples.map((message) => message.type));
expect(sampleTypes).toEqual(repoTypes);
});

test("messages round-trip identically across both implementations", () => {
for (const message of samples) {
const repoWire = repoEncode(message);
const relayWire = relayEncode(message);
expect(relayWire).toBe(repoWire);
expect(relayParse(repoWire)).toEqual(message);
expect(repoParse(relayWire)).toEqual(message);
}
});
});
11 changes: 11 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions packages/backend/convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ import type * as http from "../http.js";
import type * as lib_errors from "../lib/errors.js";
import type * as lib_logger from "../lib/logger.js";
import type * as lib_middleware from "../lib/middleware.js";
import type * as lib_onboarding from "../lib/onboarding.js";
import type * as lib_rateLimit from "../lib/rateLimit.js";
import type * as lib_relayTicket from "../lib/relayTicket.js";
import type * as lib_sessionConfig from "../lib/sessionConfig.js";
import type * as lib_types from "../lib/types.js";
import type * as onboarding from "../onboarding.js";
import type * as relay from "../relay.js";
import type * as session from "../session.js";

Expand All @@ -31,9 +34,12 @@ declare const fullApi: ApiFromModules<{
"lib/errors": typeof lib_errors;
"lib/logger": typeof lib_logger;
"lib/middleware": typeof lib_middleware;
"lib/onboarding": typeof lib_onboarding;
"lib/rateLimit": typeof lib_rateLimit;
"lib/relayTicket": typeof lib_relayTicket;
"lib/sessionConfig": typeof lib_sessionConfig;
"lib/types": typeof lib_types;
onboarding: typeof onboarding;
relay: typeof relay;
session: typeof session;
}>;
Expand Down
Loading
Loading