Skip to content
Closed
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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ on:
pull_request:
branches: [main, staging]

# Queue new CI runs when a commit arrives on the same ref.
# New runs queue instead of cancelling each other — prevents
# the single self-hosted macOS arm64 runner from being monopolised.
# Cancel in-progress CI runs when a new commit arrives on the same ref.
# This prevents multiple stale runs from queuing behind each other and
# monopolising the self-hosted macOS arm64 runner.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: false
cancel-in-progress: true

jobs:
# Detect which paths changed so downstream jobs can skip when only
Expand Down
17 changes: 9 additions & 8 deletions canvas/src/app/__tests__/orgs-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* - Polling: provisioning orgs schedule a 5s refresh (fake timers)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act } from "react";
import { render, screen, cleanup } from "@testing-library/react";

// ── Hoisted mocks ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -129,7 +130,7 @@ describe("/orgs — error state", () => {
mockFetchSession.mockResolvedValue({ userId: "u-1" });
mockFetch.mockResolvedValueOnce(notOk(500, "db down"));
render(<OrgsPage />);
await vi.advanceTimersByTimeAsync(50);
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
expect(screen.getByText(/Error:/)).toBeTruthy();
expect(screen.getByRole("button", { name: /retry/i })).toBeTruthy();
});
Expand All @@ -140,7 +141,7 @@ describe("/orgs — empty list", () => {
mockFetchSession.mockResolvedValue({ userId: "u-1" });
mockFetch.mockResolvedValueOnce(okJson({ orgs: [] }));
render(<OrgsPage />);
await vi.advanceTimersByTimeAsync(50);
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
expect(screen.getByText(/don't have any organizations/i)).toBeTruthy();
expect(screen.getByRole("button", { name: /create organization/i })).toBeTruthy();
});
Expand All @@ -167,7 +168,7 @@ describe("/orgs — CTAs by status", () => {
})
);
render(<OrgsPage />);
await vi.advanceTimersByTimeAsync(50);
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
const link = screen.getByRole("link", { name: /open/i }) as HTMLAnchorElement;
expect(link.href).toBe("https://acme.moleculesai.app/");
});
Expand All @@ -190,7 +191,7 @@ describe("/orgs — CTAs by status", () => {
})
);
render(<OrgsPage />);
await vi.advanceTimersByTimeAsync(50);
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
const link = screen.getByRole("link", {
name: /complete payment/i,
}) as HTMLAnchorElement;
Expand All @@ -215,7 +216,7 @@ describe("/orgs — CTAs by status", () => {
})
);
render(<OrgsPage />);
await vi.advanceTimersByTimeAsync(50);
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
const link = screen.getByRole("link", {
name: /contact support/i,
}) as HTMLAnchorElement;
Expand Down Expand Up @@ -244,7 +245,7 @@ describe("/orgs — post-checkout banner", () => {
})
);
render(<OrgsPage />);
await vi.advanceTimersByTimeAsync(50);
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
expect(screen.getByText(/Payment confirmed/i)).toBeTruthy();
// URL must be rewritten to drop the ?checkout flag so reload doesn't re-show the banner
expect(replaceState).toHaveBeenCalled();
Expand All @@ -256,7 +257,7 @@ describe("/orgs — post-checkout banner", () => {
mockFetchSession.mockResolvedValue({ userId: "u-1" });
mockFetch.mockResolvedValueOnce(okJson({ orgs: [] }));
render(<OrgsPage />);
await vi.advanceTimersByTimeAsync(50);
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
expect(screen.getByText(/don't have any organizations/i)).toBeTruthy();
expect(screen.queryByText(/Payment confirmed/i)).toBeNull();
});
Expand All @@ -267,7 +268,7 @@ describe("/orgs — fetch includes credentials + timeout signal", () => {
mockFetchSession.mockResolvedValue({ userId: "u-1" });
mockFetch.mockResolvedValueOnce(okJson({ orgs: [] }));
render(<OrgsPage />);
await vi.advanceTimersByTimeAsync(50);
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
const callArgs = mockFetch.mock.calls.find((c) =>
String(c[0]).includes("/cp/orgs")
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ describe("ContextMenu — keyboard accessibility", () => {
expect(mockStore.setPendingDelete).toHaveBeenCalledWith({
id: "ws-1",
name: "Alpha Workspace",
hasChildren: false,
});
expect(closeContextMenu).toHaveBeenCalled();
});
Expand Down
18 changes: 15 additions & 3 deletions workspace-server/internal/handlers/container_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,27 @@ func (h *TemplatesHandler) execInContainer(ctx context.Context, containerName st
}

// copyFilesToContainer creates a tar archive from a map of files and copies it into a container.
// The destPath is prepended to each file name. File names must be relative and must not escape
// destPath via ".." segments — otherwise the tar header name could escape the mounted volume.
func (h *TemplatesHandler) copyFilesToContainer(ctx context.Context, containerName, destPath string, files map[string]string) error {
var buf bytes.Buffer
tw := tar.NewWriter(&buf)

createdDirs := map[string]bool{}
for name, content := range files {
// Block absolute paths and traversal attempts at the archive-write boundary.
// Files are written inside destPath (typically /configs); anything that escapes
// via ".." or an absolute name could reach other volumes or system paths.
clean := filepath.Clean(name)
if filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") {
return fmt.Errorf("unsafe file path in archive: %s", name)
}
// Prepend destPath so relative paths land inside the volume mount.
archiveName := filepath.Join(destPath, name)

// Create parent directories in tar (deduplicated)
dir := filepath.Dir(name)
if dir != "." && !createdDirs[dir] {
dir := filepath.Dir(archiveName)
if dir != destPath && !createdDirs[dir] {
tw.WriteHeader(&tar.Header{
Typeflag: tar.TypeDir,
Name: dir + "/",
Expand All @@ -86,7 +98,7 @@ func (h *TemplatesHandler) copyFilesToContainer(ctx context.Context, containerNa

data := []byte(content)
header := &tar.Header{
Name: name,
Name: archiveName,
Mode: 0644,
Size: int64(len(data)),
}
Expand Down
Loading