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
1 change: 1 addition & 0 deletions .github/workflows/cd-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ jobs:
run: npm run iac:deploy -- --stage "$ALCHEMY_STAGE"

- name: Comment preview URLs
continue-on-error: true
run: |
if [ "${{ steps.deploy-secrets.outputs.ready }}" = "true" ]; then
cat > /tmp/code-colony-preview.md <<EOF
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ jobs:
run: |
if [ -n "${CONVEX_DEPLOY_KEY:-}" ]; then
echo "ready=true" >> "$GITHUB_OUTPUT"
elif [ "${{ github.event_name }}" = "push" ]; then
echo "::error::CONVEX_DEPLOY_KEY missing on push to main — convex leg cannot run."
exit 1
else
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "::notice::CONVEX_DEPLOY_KEY not configured (e.g. fork PR); skipping convex leg with success."
Expand Down
38 changes: 38 additions & 0 deletions __tests__/runCommandTool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,28 @@ describe("rejectionReason / isAllowed", () => {
}
});

it("rejects git --output (=PATH and space-PATH bypass)", () => {
for (const cmd of [
"git diff --output=/tmp/pwn",
"git diff --output /tmp/pwn",
]) {
expect(isAllowed(cmd, DEFAULT_ALLOWED_COMMANDS)).toBe(false);
const reason = rejectionReason(cmd, DEFAULT_ALLOWED_COMMANDS);
expect(reason).toMatch(/--output is not allowed/);
}
});

it("rejects rg --pre (=CMD and space-CMD subprocess bypass)", () => {
for (const cmd of [
"rg hello --pre=touch .",
"rg hello --pre touch",
]) {
expect(isAllowed(cmd, DEFAULT_ALLOWED_COMMANDS)).toBe(false);
const reason = rejectionReason(cmd, DEFAULT_ALLOWED_COMMANDS);
expect(reason).toMatch(/--pre is not allowed/);
}
});

it("does NOT include node --version in the default allow-list (--eval bypass)", () => {
expect(DEFAULT_ALLOWED_COMMANDS).not.toContain("node --version");
expect(
Expand Down Expand Up @@ -252,4 +274,20 @@ describe("runCommand tool", () => {
path: "/exec",
} satisfies Partial<SandboxError>);
});

it("strips trailing slash from baseUrl to avoid double-slash paths", async () => {
let requestedUrl = "";
const fetchImpl: typeof fetch = vi.fn(async (input) => {
requestedUrl = String(input);
return new Response(JSON.stringify({ ok: true, command: "ls", sandboxId: "x", exitCode: 0, stdout: "", stderr: "", durationMs: 1 }), { status: 200 });
}) as unknown as typeof fetch;
const sandbox = new SandboxClient({
baseUrl: "https://sandbox.example/",
hmacSecret: "secret",
fetchImpl,
now: () => 1700000000000,
});
await sandbox.exec({ command: "ls" });
expect(requestedUrl).toBe("https://sandbox.example/exec");
});
});
2 changes: 2 additions & 0 deletions __tests__/triageRouteCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ describe("triage route core", () => {
it("isBacklogIssueLike validates required fields", () => {
expect(isBacklogIssueLike(sampleIssue)).toBe(true);
expect(isBacklogIssueLike({ ...sampleIssue, id: 42 })).toBe(false);
expect(isBacklogIssueLike({ ...sampleIssue, source: 42 })).toBe(false);
expect(isBacklogIssueLike({ ...sampleIssue, externalRef: 42 })).toBe(false);
expect(isBacklogIssueLike(null)).toBe(false);
});

Expand Down
12 changes: 3 additions & 9 deletions alchemy/dns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
*/
import {
CustomDomain,
DnsRecords,
createCloudflareApi,
getZoneByDomain,
} from "alchemy/cloudflare";
Expand Down Expand Up @@ -53,15 +52,10 @@ export async function dnsStack(
`verify delegation before redeploying.`,
);
}
const zoneId = zone.id;

// Landing apex/www: deliberately not managed by alchemy. Cloudflare's
// zone-create flow auto-provisioned `@`/`www` CNAME records (Cloudflare's
// standard zone bootstrap), and recreating them collides with code 81053.
// The Pages project (alchemy/landing.ts adopts it) is independently routed
// by those existing records; alchemy can re-take ownership in a follow-up
// PR after the conflicting records are deleted manually.
void args.landing;
void zone.id;

// Landing apex/www: deliberately not managed by alchemy.

if (args.app.hostname) {
await CustomDomain("app", {
Expand Down
2 changes: 1 addition & 1 deletion app/server/repoScan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const REPO_SCAN_TTL_MS = 3_000;
const repoScanMemo = createKeyedTtlMemo<string, RepoRouteResponse>(
(key) =>
runRepoScanRequest({
searchParams: new URLSearchParams(key === "__default__" ? "" : `cwd=${key}`),
searchParams: new URLSearchParams(key === "__default__" ? undefined : { cwd: key }),
data: demoData,
}),
{ ttlMs: REPO_SCAN_TTL_MS },
Expand Down
4 changes: 2 additions & 2 deletions apps/landing/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,9 @@ export function App() {
<>
<TopNav onWaitlist={() => setWaitlistOpen(true)} />
{tw.tickerOn && <StatusTicker />}
<a id="top"></a>
<div id="top" aria-hidden="true" />
<section className="hero" id="hero">
<div className="hero-bg"></div>
<div className="hero-bg" aria-hidden="true" />
<div className="container">
<Hero
Comment thread
LaloLalo1999 marked this conversation as resolved.
headline={tw.headline}
Expand Down
4 changes: 2 additions & 2 deletions apps/landing/src/hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function HeroVariantA({ headline, subhead, onWaitlist }: HeroProps) {
className="btn primary lg"
onClick={() =>
document
.getElementById("demo")
.getElementById("vignette")
?.scrollIntoView({ behavior: "smooth" })
}
>
Expand Down Expand Up @@ -75,7 +75,7 @@ export function HeroVariantB({ headline, subhead, onWaitlist }: HeroProps) {
className="btn primary lg"
onClick={() =>
document
.getElementById("demo")
.getElementById("vignette")
?.scrollIntoView({ behavior: "smooth" })
}
>
Expand Down
2 changes: 1 addition & 1 deletion apps/landing/src/sections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -814,7 +814,7 @@ export function Footer() {
</a>
</li>
<li>
<a href="../design-system.html">Design system</a>
<a href="docs/design/design-system.html">Design system</a>
</li>
<li>
<a href="#faq">FAQ</a>
Expand Down
6 changes: 5 additions & 1 deletion apps/landing/src/tweaks-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,13 +177,17 @@ export function TweaksPanel({
}, [open, clampToViewport]);

React.useEffect(() => {
const isEmbedded = window.parent !== window;
const onMsg = (e: MessageEvent) => {
if (!isEmbedded || e.source !== window.parent) return;
const t = (e as any)?.data?.type;
if (t === "__activate_edit_mode") setOpen(true);
else if (t === "__deactivate_edit_mode") setOpen(false);
};
window.addEventListener("message", onMsg);
window.parent.postMessage({ type: "__edit_mode_available" }, "*");
if (isEmbedded) {
window.parent.postMessage({ type: "__edit_mode_available" }, "*");
}
return () => window.removeEventListener("message", onMsg);
}, []);

Expand Down
11 changes: 11 additions & 0 deletions apps/sandbox-runner/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,15 @@ describe("verifyHmac", () => {
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBe("invalid timestamp");
});

it("rejects bodies larger than MAX_BODY_BYTES", async () => {
const bigBody = "x".repeat(1 * 1024 * 1024 + 1);
const req = await signedRequest(bigBody);
const result = await verifyHmac(req, SECRET, { now: () => NOW });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBe("body too large");
expect(result.status).toBe(413);
}
});
});
44 changes: 37 additions & 7 deletions apps/sandbox-runner/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,26 @@ const ENCODER = new TextEncoder();
export const TIMESTAMP_HEADER = "x-sandbox-ts";
export const SIGNATURE_HEADER = "x-sandbox-sig";
export const DEFAULT_SKEW_MS = 5 * 60 * 1000;
export const MAX_BODY_BYTES = 1 * 1024 * 1024;


const keyCache = new Map<string, CryptoKey>();

async function importKeyCached(secret: string): Promise<CryptoKey> {
let key = keyCache.get(secret);
if (!key) {
key = await crypto.subtle.importKey(
"raw",
ENCODER.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
keyCache.set(secret, key);
}
return key;
}


export interface VerifyHmacOptions {
/** Time source, defaults to `Date.now`. Injected for testing. */
Expand All @@ -41,13 +61,7 @@ export async function signHmac(
body: string,
secret: string,
): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
ENCODER.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const key = await importKeyCached(secret);
const sig = await crypto.subtle.sign(
"HMAC",
key,
Expand All @@ -56,6 +70,8 @@ export async function signHmac(
return bytesToHex(new Uint8Array(sig));
}



/**
* Validate the auth headers on a Worker request. Returns the consumed body on
* success so the handler can `JSON.parse` it directly.
Expand All @@ -82,7 +98,21 @@ export async function verifyHmac(
return { ok: false, error: "timestamp expired", status: 401 };
}

const contentLength = request.headers.get("content-length");
if (contentLength && Number.parseInt(contentLength, 10) > MAX_BODY_BYTES) {
return { ok: false, error: "body too large", status: 413 };
}

const body = await request.text();
// body.length counts UTF-16 code units; a body containing multi-byte
// characters can fool that check while still exceeding MAX_BODY_BYTES on
// the wire. TextEncoder gives us the actual UTF-8 byte length.
const bodyBytes = new TextEncoder().encode(body).byteLength;
if (bodyBytes > MAX_BODY_BYTES) {
return { ok: false, error: "body too large", status: 413 };
}


const expected = await signHmac(timestamp, body, secret);
if (!constantTimeEqualHex(expected, signature)) {
return { ok: false, error: "invalid signature", status: 401 };
Expand Down
16 changes: 14 additions & 2 deletions apps/sandbox-runner/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import { getSandbox, type Sandbox as SandboxClass } from "@cloudflare/sandbox";
import { verifyHmac } from "./auth";
import type {
ErrorResponseBody,
ExecRequest,
ExecResponse,
GitCloneRequest,
GitCloneResponse,
Expand Down Expand Up @@ -120,7 +119,7 @@ async function handleGitClone(
});
const body: GitCloneResponse = {
ok: result.success,
repoUrl: result.repoUrl,
repoUrl: redactCredentials(result.repoUrl),
branch: result.branch,
targetDir: result.targetDir,
exitCode: result.exitCode,
Expand Down Expand Up @@ -153,6 +152,19 @@ function optionalString(value: unknown): string | undefined {
function optionalNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function redactCredentials(url: string): string {
try {
const u = new URL(url);
if (u.password || u.username) {
u.password = "";
u.username = "";
return u.toString();
}
} catch {
// Not a valid URL — return unchanged.
}
return url;
}

function toErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message) return error.message;
Expand Down
7 changes: 5 additions & 2 deletions docs/runbooks/rollback.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,15 @@ If the dashboard promotion is unavailable, check out the known-good commit and r

## Convex rollback

Convex deploys are versioned by deployment. Identify the previous healthy version in the Convex dashboard or release notes, then run the targeted version command from the repo root:
Convex deploys are versioned by deployment. Identify the previous healthy version in the Convex dashboard or release notes, then redeploy that version from the repo root:

```bash
npx convex run --version <prev>
# Checkout the known-good commit and redeploy
npx convex deploy
```

Or promote the previous deployment directly in the Convex dashboard (Deployments → select version → Promote).

If schema changes are involved, confirm the previous functions are compatible with current data before rollback. For destructive schema mistakes, stop traffic at the Worker layer first, snapshot/export affected data where possible, then restore the last known compatible Convex version.

## After rollback
Expand Down
2 changes: 2 additions & 0 deletions mastra/sandboxClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ export class SandboxClient {
private readonly timeoutMs: number;

constructor(private readonly config: SandboxClientConfig) {
const url = config.baseUrl.replace(/\/$/, "");
this.config = { ...config, baseUrl: url };
this.fetchImpl = config.fetchImpl ?? fetch;
this.now = config.now ?? Date.now;
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
Expand Down
16 changes: 16 additions & 0 deletions mastra/tools/runCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,22 @@ export function rejectionReason(
if (!matches) {
return `not in allow-list: ${allowList.join(", ")}`;
}
// Reject git commands that write to disk via --output (in either
// --output=PATH or --output PATH form).
if (
command.startsWith("git ") &&
/(?:^|\s)--output(?:=|\s)/.test(command)
) {
return "git --output is not allowed (write-capable flag)";
}
// Reject ripgrep commands that execute arbitrary preprocessors
// (--pre=CMD or --pre CMD).
if (
command.startsWith("rg ") &&
/(?:^|\s)--pre(?:=|\s)/.test(command)
) {
return "rg --pre is not allowed (arbitrary subprocess execution)";
}
return null;
}

Expand Down
2 changes: 1 addition & 1 deletion scripts/smoke.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# AGE-62 smoke tests: exercise each Convex Component end-to-end against the
# dev deployment. Run after `npx convex dev --once` succeeds.
set -e
set -euo pipefail
cd "$(dirname "$0")/.."

run() { printf '\n== %s ==\n' "$1"; npx convex run "$@" 2>&1 | tail -5; }
Expand Down
2 changes: 2 additions & 0 deletions src/server/triageRouteCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export function isBacklogIssueLike(value: unknown): value is BacklogIssue {
const candidate = value as Partial<BacklogIssue>;
return (
typeof candidate.id === "string" &&
typeof candidate.source === "string" &&
typeof candidate.externalRef === "string" &&
typeof candidate.title === "string" &&
typeof candidate.summary === "string" &&
typeof candidate.suggestedRegionId === "string"
Expand Down
Loading