mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
10a78d0527
## What A new `tar-upload` file-input kind so a tool that reads a local file or directory (e.g. `reinstall-app`'s app bundle) works when the tool-server runs on a different machine than the caller. ## Why `reinstall-app` assumes the bundle is on the tool-server's filesystem. That breaks with a cloud-hosted simulator — the bundle is on your machine, not the server's. The existing `file` kind inlines base64 and is capped at 32 MB, too small for a real `.app`/`.apk`; `tar-upload` streams instead. ## How When you're not linked to a remote server, an existing local path is used in place. When you are (e.g. `argent link`), the client tars the path, streams it to `POST /upload`, and the tool call carries `uploadId` plus a SHA-256 `contentHash`. The server always extracts from that upload — even if the same path already exists on disk — verifies the hash, and vets tar members before extracting. The client only uploads when the path is on its own machine (a line on stderr so you know it's working). Same command local or remote: ``` argent run reinstall-app --udid <udid> --bundleId <id> --appPath ./MyApp.app ``` ## Shared extraction Per review feedback, the tar pack/unpack logic is unified in a small `@argent/archive` package used by **both** directions — this upload path and the pre-existing artifact download (`streamDirectoryAsTarGz` / `downloadAndExtractArchive`). The shared extractor rejects path-traversal entries, symlinks whose target escapes the extract dir, and non-file/dir members (hardlinks, devices) — while still allowing a bundle's internal symlinks. This also hardens the artifact-download path, which previously extracted without those checks. ## Tests Client + server unit and HTTP round-trip tests: dir and file upload, skip-when-not-local, co-located no-op, hash required, path-traversal / escaping-symlink / hardlink rejection, sidecar handling, single-member resolution, `/upload` auth + size caps, and artifact download extraction. All suites pass. --------- Co-authored-by: Filip131311 <contact@filipkaminski.com>
170 lines
5.7 KiB
TypeScript
170 lines
5.7 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import supertest from "supertest";
|
|
import { createHttpApp, type HttpAppHandle } from "../src/http";
|
|
import type { Registry } from "@argent/registry";
|
|
|
|
vi.mock("../src/utils/update-checker", () => ({
|
|
getUpdateState: vi.fn(() => ({
|
|
updateAvailable: false,
|
|
latestVersion: null,
|
|
currentVersion: "1.0.0",
|
|
})),
|
|
isUpdateNoteSuppressed: vi.fn(() => true),
|
|
suppressUpdateNote: vi.fn(),
|
|
}));
|
|
|
|
function stubRegistry(): Registry {
|
|
return {
|
|
getSnapshot: vi.fn(() => ({ services: new Map(), namespaces: [], tools: [] })),
|
|
getTool: vi.fn(() => undefined),
|
|
invokeTool: vi.fn(async () => ({ ok: true })),
|
|
} as unknown as Registry;
|
|
}
|
|
|
|
const TOKEN = "abc123def456";
|
|
|
|
describe("Authorization gate", () => {
|
|
let handle: HttpAppHandle;
|
|
let request: typeof supertest;
|
|
let originalToken: string | undefined;
|
|
|
|
beforeEach(async () => {
|
|
request = supertest;
|
|
originalToken = process.env.ARGENT_AUTH_TOKEN;
|
|
process.env.ARGENT_AUTH_TOKEN = TOKEN;
|
|
handle = createHttpApp(stubRegistry());
|
|
});
|
|
|
|
afterEach(() => {
|
|
handle?.dispose();
|
|
if (originalToken === undefined) delete process.env.ARGENT_AUTH_TOKEN;
|
|
else process.env.ARGENT_AUTH_TOKEN = originalToken;
|
|
});
|
|
|
|
it("accepts a request with the correct Bearer token", async () => {
|
|
const res = await request(handle.app).get("/tools").set("Authorization", `Bearer ${TOKEN}`);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("rejects with 401 when Authorization header is missing", async () => {
|
|
const res = await request(handle.app).get("/tools");
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.error).toMatch(/Authorization/i);
|
|
});
|
|
|
|
it("rejects with 401 when token is wrong", async () => {
|
|
const res = await request(handle.app).get("/tools").set("Authorization", "Bearer wrong-token");
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("rejects when scheme is not Bearer", async () => {
|
|
const res = await request(handle.app).get("/tools").set("Authorization", `Basic ${TOKEN}`);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("rejects when token is the right length but wrong content (constant-time path)", async () => {
|
|
const wrongSameLength = "x".repeat(TOKEN.length);
|
|
const res = await request(handle.app)
|
|
.get("/tools")
|
|
.set("Authorization", `Bearer ${wrongSameLength}`);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("blocks POST /tools/:name without auth", async () => {
|
|
const res = await request(handle.app).post("/tools/anything").send({});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("blocks /shutdown without auth", async () => {
|
|
handle.dispose();
|
|
handle = createHttpApp(stubRegistry(), { onShutdown: () => {} });
|
|
const res = await request(handle.app).post("/shutdown");
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("blocks /registry/snapshot without auth", async () => {
|
|
const res = await request(handle.app).get("/registry/snapshot");
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("blocks POST /upload without auth", async () => {
|
|
const res = await request(handle.app)
|
|
.post("/upload")
|
|
.set("Content-Type", "application/gzip")
|
|
.send(Buffer.from("gz"));
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("accepts POST /upload with the correct Bearer token", async () => {
|
|
const res = await request(handle.app)
|
|
.post("/upload")
|
|
.set("Authorization", `Bearer ${TOKEN}`)
|
|
.set("Content-Type", "application/gzip")
|
|
.send(Buffer.from("gz"));
|
|
expect(res.status).toBe(200);
|
|
expect(typeof res.body.uploadId).toBe("string");
|
|
});
|
|
|
|
it("exempts the /preview UI subtree from auth", async () => {
|
|
// The browser-loaded preview UI has no token; /preview and /preview/* must
|
|
// stay reachable. (stub registry has no UI html → not 200, but must not 401.)
|
|
const res = await request(handle.app).get("/preview/");
|
|
expect(res.status).not.toBe(401);
|
|
});
|
|
|
|
it("does NOT exempt a non-/preview/ path that merely starts with 'preview' (footgun closed)", async () => {
|
|
// Guards the tightened exemption: a bare startsWith('/preview') used to
|
|
// leave a route like /preview-status silently unauthenticated.
|
|
const res = await request(handle.app).get("/preview-status");
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe("Authorization gate (token unset / dev mode)", () => {
|
|
let handle: HttpAppHandle;
|
|
let request: typeof supertest;
|
|
let originalToken: string | undefined;
|
|
|
|
beforeEach(async () => {
|
|
request = supertest;
|
|
originalToken = process.env.ARGENT_AUTH_TOKEN;
|
|
delete process.env.ARGENT_AUTH_TOKEN;
|
|
handle = createHttpApp(stubRegistry());
|
|
});
|
|
|
|
afterEach(() => {
|
|
handle?.dispose();
|
|
if (originalToken === undefined) delete process.env.ARGENT_AUTH_TOKEN;
|
|
else process.env.ARGENT_AUTH_TOKEN = originalToken;
|
|
});
|
|
|
|
it("permits unauthenticated requests when ARGENT_AUTH_TOKEN is unset (dev mode)", async () => {
|
|
const res = await request(handle.app).get("/tools");
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe("CORS removal", () => {
|
|
let handle: HttpAppHandle;
|
|
let request: typeof supertest;
|
|
let originalToken: string | undefined;
|
|
|
|
beforeEach(async () => {
|
|
request = supertest;
|
|
originalToken = process.env.ARGENT_AUTH_TOKEN;
|
|
delete process.env.ARGENT_AUTH_TOKEN;
|
|
handle = createHttpApp(stubRegistry());
|
|
});
|
|
|
|
afterEach(() => {
|
|
handle?.dispose();
|
|
if (originalToken === undefined) delete process.env.ARGENT_AUTH_TOKEN;
|
|
else process.env.ARGENT_AUTH_TOKEN = originalToken;
|
|
});
|
|
|
|
it("does not emit Access-Control-Allow-Origin: *", async () => {
|
|
const res = await request(handle.app).get("/tools").set("Origin", "https://evil.example");
|
|
expect(res.headers["access-control-allow-origin"]).toBeUndefined();
|
|
});
|
|
});
|