refactor(daytona): simplify the login session-home create (#12348)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Sandbox providers let agents run in remote environments > - The Daytona login flow creates a home directory for each login session > - The create path ran owner, mode, and link-type checks inside the sandbox > - These checks cannot protect the host because sandbox code can change the checked state > - This pull request uses one `mkdir -p` command and removes the unused helper scripts > - The benefit is a simpler login path with the host-side credential checks unchanged ## Linked Issues or Issue Description **What happened?** The Daytona login flow used helper scripts and inside-sandbox checks for the session-home directory. The standalone package build also copied a scripts directory that no longer existed after the helper scripts were removed. **Expected behavior** The login flow must create the session home with one `mkdir -p` command. The package build must complete without copying a removed directory. **Steps to reproduce** 1. Build the Daytona plugin package. 2. Start a Daytona device login. 3. Inspect the session-home create command and the package output. **Paperclip version or commit** Commit `dfdf5914ba37caa1e3bc380236844a7d76237e12`. **Deployment mode** Built from source. ## What Changed - Replace the session-home helper checks with one `mkdir -p` command. - Remove the two unused session-home helper scripts. - Remove the dead build copy steps for the deleted scripts directory. - Add coverage for a failed session-home create command. - Keep the host-side credential reader unchanged. - Keep the Kubernetes provider package unchanged. ## Verification - Package unit tests pass: 220 passed, 6 skipped. - Package typecheck passes. - The package build passes and emits 56 files in `dist`. - The roadmap check confirms that this change stays within the planned sandbox-provider work. - GitHub search found no open duplicate or related pull request. ## Risks The login flow no longer reports owner, mode, or link-type errors from inside the sandbox. Those checks did not protect the host. The host-side credential reader still uses no-follow path opens and accepts only a regular file with owner and exact mode `0600`. Risk is low because this change removes checks that cannot enforce the host security boundary. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. This pull request updates an existing sandbox-provider path, not a new core feature. ## Model Used OpenAI Codex, GPT-5. The runtime provides tool use and code execution. The runtime does not expose the context window size or a more specific model identifier. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
3d9be6f7fe
commit
17ebcc65b7
|
|
@ -42,7 +42,7 @@
|
|||
],
|
||||
"scripts": {
|
||||
"prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "rm -rf dist && tsc && mkdir -p dist/scripts && cp -R src/scripts/. dist/scripts/",
|
||||
"build": "rm -rf dist && tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
composeLaunchLine,
|
||||
createDaytonaLoginHomeFs,
|
||||
|
|
@ -15,7 +11,6 @@ import {
|
|||
type DaytonaPtyHandle,
|
||||
type DaytonaPtyProcess,
|
||||
type DaytonaSandboxExec,
|
||||
type LoginHomeInspection,
|
||||
type LoginPtyLaunchDescriptor,
|
||||
} from "./login-pty.js";
|
||||
|
||||
|
|
@ -23,70 +18,21 @@ import {
|
|||
const ENTER = "\r";
|
||||
|
||||
const HOME = "/tmp/paperclip-adapter-login/11111111-2222-4333-8444-555555555555";
|
||||
const LOGIN_UID = 1000;
|
||||
|
||||
const CLAUDE: LoginPtyLaunchDescriptor = { loginCommandKey: "claude", sessionHome: HOME };
|
||||
const CODEX: LoginPtyLaunchDescriptor = { loginCommandKey: "codex", sessionHome: HOME };
|
||||
|
||||
/** A directory entry in the virtual filesystem. */
|
||||
type FakeEntry = Omit<LoginHomeInspection, "exists">;
|
||||
|
||||
const ABSENT: LoginHomeInspection = {
|
||||
exists: false,
|
||||
isSymlink: false,
|
||||
isDirectory: false,
|
||||
mode: "",
|
||||
ownerUid: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* A fake login-home filesystem. It models one virtual path. `createDirectory`
|
||||
* fails when the path exists and otherwise stores `createAs` (a clean 0700
|
||||
* directory owned by the login user by default). A test seeds a pre-existing
|
||||
* entry, forces a bad created entry, or swaps the entry through `onCreatePty`, so
|
||||
* a test drives each rejection and the pre-launch re-check.
|
||||
* A fake login-home filesystem. It records each path the caller asks to create
|
||||
* and never fails, matching the `mkdir -p` semantics of the real command: a
|
||||
* repeat create for the same path succeeds the same as a fresh one.
|
||||
*/
|
||||
function createFakeHomeFs(config?: {
|
||||
loginUid?: number;
|
||||
seed?: FakeEntry;
|
||||
createAs?: FakeEntry;
|
||||
createShouldFail?: boolean;
|
||||
}): DaytonaLoginHomeFs & {
|
||||
created: Array<{ path: string; mode: string }>;
|
||||
inspectCount: number;
|
||||
setEntry: (entry: FakeEntry | null) => void;
|
||||
} {
|
||||
const loginUid = config?.loginUid ?? LOGIN_UID;
|
||||
const cleanDir: FakeEntry = {
|
||||
isSymlink: false,
|
||||
isDirectory: true,
|
||||
mode: "700",
|
||||
ownerUid: loginUid,
|
||||
};
|
||||
let entry: FakeEntry | null = config?.seed ?? null;
|
||||
const created: Array<{ path: string; mode: string }> = [];
|
||||
const state = { inspectCount: 0 };
|
||||
function createFakeHomeFs(): DaytonaLoginHomeFs & { created: string[] } {
|
||||
const created: string[] = [];
|
||||
return {
|
||||
created,
|
||||
get inspectCount() {
|
||||
return state.inspectCount;
|
||||
},
|
||||
setEntry(next: FakeEntry | null): void {
|
||||
entry = next;
|
||||
},
|
||||
async loginUserId(): Promise<number> {
|
||||
return loginUid;
|
||||
},
|
||||
async inspect(): Promise<LoginHomeInspection> {
|
||||
state.inspectCount += 1;
|
||||
return entry ? { exists: true, ...entry } : ABSENT;
|
||||
},
|
||||
async createDirectory(path: string, mode: string): Promise<void> {
|
||||
if (config?.createShouldFail || entry) {
|
||||
throw new Error("LOGIN_PTY_HOME_REJECTED");
|
||||
}
|
||||
created.push({ path, mode });
|
||||
entry = config?.createAs ?? cleanDir;
|
||||
async createDirectory(path: string): Promise<void> {
|
||||
created.push(path);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -154,11 +100,9 @@ function createFakePtyHandle(
|
|||
|
||||
/**
|
||||
* A fake Daytona process. It opens one fake PTY handle and records the create
|
||||
* options, so a test asserts the terminal size and the launch line. `onCreatePty`
|
||||
* runs when the session opens the terminal, so a test swaps a symlink in after
|
||||
* the directory creation but before the launch input.
|
||||
* options, so a test asserts the terminal size and the launch line.
|
||||
*/
|
||||
function createFakeProcess(onCreatePty?: () => void): DaytonaPtyProcess & {
|
||||
function createFakeProcess(): DaytonaPtyProcess & {
|
||||
handle: ReturnType<typeof createFakePtyHandle> | null;
|
||||
createOptions: DaytonaPtyCreateOptions | null;
|
||||
createCount: number;
|
||||
|
|
@ -181,7 +125,6 @@ function createFakeProcess(onCreatePty?: () => void): DaytonaPtyProcess & {
|
|||
async createPty(options: DaytonaPtyCreateOptions): Promise<DaytonaPtyHandle> {
|
||||
state.createCount += 1;
|
||||
state.createOptions = options;
|
||||
onCreatePty?.();
|
||||
const handle = createFakePtyHandle(options.onData);
|
||||
state.handle = handle;
|
||||
return handle;
|
||||
|
|
@ -217,15 +160,14 @@ describe("composeLaunchLine", () => {
|
|||
});
|
||||
|
||||
describe("openDaytonaLoginPtySession — session home", () => {
|
||||
it("creates the exact UUID directory as a new 0700 directory owned by the login user", async () => {
|
||||
it("creates the session home directory", async () => {
|
||||
const process = createFakeProcess();
|
||||
const fs = createFakeHomeFs();
|
||||
|
||||
await openDaytonaLoginPtySession(process, fs, CLAUDE);
|
||||
|
||||
// The provider created the exact directory as a new 0700 directory.
|
||||
expect(fs.created).toEqual([{ path: HOME, mode: "700" }]);
|
||||
// The terminal opened and the launch line ran.
|
||||
// The provider created the exact directory and opened the terminal.
|
||||
expect(fs.created).toEqual([HOME]);
|
||||
expect(process.createCount).toBe(1);
|
||||
expect(process.handle?.inputs[0]).toBe("exec claude setup-token" + ENTER);
|
||||
});
|
||||
|
|
@ -243,94 +185,18 @@ describe("openDaytonaLoginPtySession — session home", () => {
|
|||
expect((process.handle?.inputs[0]?.match(/CODEX_HOME=/g) ?? []).length).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects a pre-existing target before it creates the directory", async () => {
|
||||
it("opens the login session when the session home already exists", async () => {
|
||||
const process = createFakeProcess();
|
||||
const fs = createFakeHomeFs({
|
||||
seed: { isSymlink: false, isDirectory: true, mode: "700", ownerUid: LOGIN_UID },
|
||||
});
|
||||
|
||||
await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow(
|
||||
"LOGIN_PTY_HOME_REJECTED",
|
||||
);
|
||||
// The provider never created the directory and never opened the terminal.
|
||||
expect(fs.created).toEqual([]);
|
||||
expect(process.createCount).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a pre-existing symlink at the target", async () => {
|
||||
const process = createFakeProcess();
|
||||
const fs = createFakeHomeFs({
|
||||
seed: { isSymlink: true, isDirectory: false, mode: "777", ownerUid: LOGIN_UID },
|
||||
});
|
||||
|
||||
await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow(
|
||||
"LOGIN_PTY_HOME_REJECTED",
|
||||
);
|
||||
expect(process.createCount).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a created target that is a symlink", async () => {
|
||||
const process = createFakeProcess();
|
||||
const fs = createFakeHomeFs({
|
||||
createAs: { isSymlink: true, isDirectory: false, mode: "700", ownerUid: LOGIN_UID },
|
||||
});
|
||||
|
||||
await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow(
|
||||
"LOGIN_PTY_HOME_REJECTED",
|
||||
);
|
||||
expect(process.createCount).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a created target that is not a directory", async () => {
|
||||
const process = createFakeProcess();
|
||||
const fs = createFakeHomeFs({
|
||||
createAs: { isSymlink: false, isDirectory: false, mode: "700", ownerUid: LOGIN_UID },
|
||||
});
|
||||
|
||||
await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow(
|
||||
"LOGIN_PTY_HOME_REJECTED",
|
||||
);
|
||||
expect(process.createCount).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a created directory with a wrong owner", async () => {
|
||||
const process = createFakeProcess();
|
||||
const fs = createFakeHomeFs({
|
||||
createAs: { isSymlink: false, isDirectory: true, mode: "700", ownerUid: LOGIN_UID + 1 },
|
||||
});
|
||||
|
||||
await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow(
|
||||
"LOGIN_PTY_HOME_REJECTED",
|
||||
);
|
||||
expect(process.createCount).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a created directory with a wrong mode", async () => {
|
||||
const process = createFakeProcess();
|
||||
const fs = createFakeHomeFs({
|
||||
createAs: { isSymlink: false, isDirectory: true, mode: "755", ownerUid: LOGIN_UID },
|
||||
});
|
||||
|
||||
await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow(
|
||||
"LOGIN_PTY_HOME_REJECTED",
|
||||
);
|
||||
expect(process.createCount).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a symlink swapped in after creation, before the launch input", async () => {
|
||||
// The directory validates cleanly, then a symlink replaces it while the
|
||||
// terminal opens. The no-symlink re-check before the launch input fails.
|
||||
const fs = createFakeHomeFs();
|
||||
const process = createFakeProcess(() => {
|
||||
fs.setEntry({ isSymlink: true, isDirectory: false, mode: "777", ownerUid: LOGIN_UID });
|
||||
});
|
||||
|
||||
await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow(
|
||||
"LOGIN_PTY_HOME_REJECTED",
|
||||
);
|
||||
// The terminal opened, but the launch input never ran.
|
||||
expect(process.createCount).toBe(1);
|
||||
expect(process.handle?.inputs).toEqual([]);
|
||||
// A second login for the same home hits an existing directory. `mkdir -p`
|
||||
// succeeds on an existing directory, so the second session opens the same
|
||||
// as the first.
|
||||
await openDaytonaLoginPtySession(process, fs, CLAUDE);
|
||||
await openDaytonaLoginPtySession(process, fs, CLAUDE);
|
||||
|
||||
expect(fs.created).toEqual([HOME, HOME]);
|
||||
expect(process.createCount).toBe(2);
|
||||
});
|
||||
|
||||
it("rejects a descriptor with a command key outside the closed set", async () => {
|
||||
|
|
@ -464,37 +330,9 @@ describe("openDaytonaLoginPtySession — session mechanics", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("openDaytonaLoginPtySession — ancestor symlink", () => {
|
||||
it("starts no pseudo-terminal when the home filesystem check fails closed", async () => {
|
||||
// A symlinked login-root ancestor makes the descriptor-relative inspection fail
|
||||
// closed. The session opener must reject before it opens the terminal, so no
|
||||
// pseudo-terminal starts and the login command never runs.
|
||||
const process = createFakeProcess();
|
||||
let created = false;
|
||||
const fs: DaytonaLoginHomeFs = {
|
||||
async loginUserId(): Promise<number> {
|
||||
return LOGIN_UID;
|
||||
},
|
||||
async inspect(): Promise<LoginHomeInspection> {
|
||||
throw new Error("LOGIN_PTY_HOME_REJECTED");
|
||||
},
|
||||
async createDirectory(): Promise<void> {
|
||||
created = true;
|
||||
},
|
||||
};
|
||||
|
||||
await expect(openDaytonaLoginPtySession(process, fs, CODEX)).rejects.toThrow(
|
||||
"LOGIN_PTY_HOME_REJECTED",
|
||||
);
|
||||
// The provider never created the directory and never opened the terminal.
|
||||
expect(created).toBe(false);
|
||||
expect(process.createCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDaytonaLoginHomeFs — login-profile preamble", () => {
|
||||
// A capturing exec surface. It records each command and returns a fixed result,
|
||||
// so a test reads the exact command string the helper runs.
|
||||
// so a test reads the exact command string the create runs.
|
||||
function createCapturingExec(result: DaytonaExecResult): DaytonaSandboxExec & {
|
||||
commands: string[];
|
||||
} {
|
||||
|
|
@ -503,125 +341,39 @@ describe("createDaytonaLoginHomeFs — login-profile preamble", () => {
|
|||
commands,
|
||||
async executeCommand(command: string): Promise<DaytonaExecResult> {
|
||||
commands.push(command);
|
||||
// `id -u` resolves the login user id. Return a fixed uid so the caller
|
||||
// continues to the node helper command under test.
|
||||
if (command.startsWith("id -u")) {
|
||||
return { exitCode: 0, result: "1000" };
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// The command sources /etc/profile before it runs node. The Daytona image may
|
||||
// expose node only through a login profile, so node must run after the profile
|
||||
// source. This assertion proves the helper runtime and the login PTY capability
|
||||
// stay consistent.
|
||||
function expectProfileBeforeNode(command: string | undefined): void {
|
||||
expect(command).toBeDefined();
|
||||
const profileIndex = command!.indexOf("/etc/profile");
|
||||
const nodeIndex = command!.indexOf("node -e");
|
||||
expect(profileIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(nodeIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(profileIndex).toBeLessThan(nodeIndex);
|
||||
}
|
||||
|
||||
it("sources the login profiles before it runs the node inspect helper", async () => {
|
||||
const exec = createCapturingExec({ exitCode: 0, result: "ABSENT" });
|
||||
const fs = createDaytonaLoginHomeFs(exec);
|
||||
await fs.inspect(HOME);
|
||||
expectProfileBeforeNode(exec.commands.find((command) => command.includes("node -e")));
|
||||
});
|
||||
|
||||
it("sources the login profiles before it runs the node create helper", async () => {
|
||||
it("sources the login profiles before it creates the session home directory", async () => {
|
||||
// The command sources /etc/profile before it runs `mkdir -p`, so the create
|
||||
// command stays consistent with the profile chain the rest of the sandbox
|
||||
// exec commands use.
|
||||
const exec = createCapturingExec({ exitCode: 0, result: "" });
|
||||
const fs = createDaytonaLoginHomeFs(exec);
|
||||
await fs.createDirectory(HOME, "700");
|
||||
expectProfileBeforeNode(exec.commands.find((command) => command.includes("node -e")));
|
||||
});
|
||||
});
|
||||
|
||||
// The real login-home filesystem runs a node helper on the sandbox. The helper
|
||||
// walks the path with `/proc/self/fd`, which is Linux only. A non-Linux host skips
|
||||
// these tests. The fake exec runs the helper locally, so the descriptor-relative
|
||||
// walk is tested against a real filesystem. The login sandbox is Linux.
|
||||
const describeLinux = process.platform === "linux" ? describe : describe.skip;
|
||||
await fs.createDirectory(HOME);
|
||||
|
||||
describeLinux("createDaytonaLoginHomeFs — descriptor-relative walk", () => {
|
||||
let root: string;
|
||||
|
||||
beforeAll(() => {
|
||||
root = mkdtempSync(path.join(tmpdir(), "daytona-login-home-"));
|
||||
});
|
||||
afterAll(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
const command = exec.commands.find((entry) => entry.includes("mkdir -p"));
|
||||
expect(command).toBeDefined();
|
||||
const profileIndex = command!.indexOf("/etc/profile");
|
||||
const mkdirIndex = command!.indexOf("mkdir -p");
|
||||
expect(profileIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(mkdirIndex).toBeGreaterThan(profileIndex);
|
||||
});
|
||||
|
||||
// A local exec surface. It runs the composed command with `/bin/sh -c`, so the
|
||||
// real node helper runs against the local filesystem.
|
||||
function createLocalExec(): DaytonaSandboxExec {
|
||||
return {
|
||||
async executeCommand(command: string): Promise<DaytonaExecResult> {
|
||||
const result = spawnSync("/bin/sh", ["-c", command], { encoding: "utf8" });
|
||||
return { exitCode: result.status ?? undefined, result: result.stdout ?? "" };
|
||||
},
|
||||
};
|
||||
}
|
||||
it("rejects the session and opens no pseudo-terminal when the create command exits non-zero", async () => {
|
||||
// The sandbox `mkdir -p` command fails (for example, a read-only mount).
|
||||
// The session must fail closed before it opens a pseudo-terminal.
|
||||
const exec = createCapturingExec({ exitCode: 1, result: "" });
|
||||
const fs = createDaytonaLoginHomeFs(exec);
|
||||
const process = createFakeProcess();
|
||||
|
||||
const UUID = "11111111-2222-4333-8444-555555555555";
|
||||
|
||||
it("reports a not-yet-created target as absent", async () => {
|
||||
const fs = createDaytonaLoginHomeFs(createLocalExec());
|
||||
const home = path.join(root, "absent-root", UUID);
|
||||
const inspection = await fs.inspect(home);
|
||||
expect(inspection.exists).toBe(false);
|
||||
});
|
||||
|
||||
it("creates the login root and the session home relative to a trusted descriptor", async () => {
|
||||
const fs = createDaytonaLoginHomeFs(createLocalExec());
|
||||
const home = path.join(root, "clean-root", UUID);
|
||||
await fs.createDirectory(home, "700");
|
||||
const inspection = await fs.inspect(home);
|
||||
expect(inspection.exists).toBe(true);
|
||||
expect(inspection.isSymlink).toBe(false);
|
||||
expect(inspection.isDirectory).toBe(true);
|
||||
expect(inspection.mode).toBe("700");
|
||||
expect(inspection.ownerUid).toBe(process.getuid ? process.getuid() : inspection.ownerUid);
|
||||
});
|
||||
|
||||
it("rejects a pre-existing session home target", async () => {
|
||||
const fs = createDaytonaLoginHomeFs(createLocalExec());
|
||||
const home = path.join(root, "exists-root", UUID);
|
||||
await fs.createDirectory(home, "700");
|
||||
// A second create of the same target fails, because the target exists.
|
||||
await expect(fs.createDirectory(home, "700")).rejects.toThrow("LOGIN_PTY_HOME_REJECTED");
|
||||
});
|
||||
|
||||
it("fails the inspection closed when the login root is a symlink", async () => {
|
||||
// Pre-place a symlink at the login-root ancestor. The descriptor-relative walk
|
||||
// opens the root with a no-follow open, so the inspection fails closed.
|
||||
const fs = createDaytonaLoginHomeFs(createLocalExec());
|
||||
const attacker = path.join(root, "inspect-attacker");
|
||||
mkdirSync(attacker, { mode: 0o700 });
|
||||
const rootLink = path.join(root, "inspect-symlink-root");
|
||||
symlinkSync(attacker, rootLink);
|
||||
const home = path.join(rootLink, UUID);
|
||||
await expect(fs.inspect(home)).rejects.toThrow("LOGIN_PTY_HOME_REJECTED");
|
||||
});
|
||||
|
||||
it("fails the creation closed when the login root is a pre-placed symlink", async () => {
|
||||
// Pre-place a symlink at the login-root ancestor that points at an attacker
|
||||
// tree. The creation opens the root with a no-follow open after the tolerant
|
||||
// `mkdir`, so it fails closed and creates no directory in the attacker tree.
|
||||
const fs = createDaytonaLoginHomeFs(createLocalExec());
|
||||
const attacker = path.join(root, "create-attacker");
|
||||
mkdirSync(attacker, { mode: 0o700 });
|
||||
const rootLink = path.join(root, "create-symlink-root");
|
||||
symlinkSync(attacker, rootLink);
|
||||
const home = path.join(rootLink, UUID);
|
||||
await expect(fs.createDirectory(home, "700")).rejects.toThrow("LOGIN_PTY_HOME_REJECTED");
|
||||
// The creation placed no session home inside the attacker tree.
|
||||
expect(existsSync(path.join(attacker, UUID))).toBe(false);
|
||||
await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow(
|
||||
"LOGIN_PTY_HOME_REJECTED",
|
||||
);
|
||||
expect(process.createCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -15,17 +15,12 @@
|
|||
// home cannot add a second shell token or a second command.
|
||||
//
|
||||
// Session home: the module revalidates the descriptor and the home shape, then
|
||||
// creates the exact UUID directory as a NEW directory with mode 0700, owned by the
|
||||
// login user. The inspection and the creation open the filesystem root, then walk
|
||||
// each path component with a no-follow, directory-only open, so a symlink at any
|
||||
// ancestor (the login root or a directory above it) fails closed. A single
|
||||
// composite `stat` or `mkdir` follows a symlink at an ancestor; the per-component
|
||||
// walk does not. The creation creates the login root if the root is absent and the
|
||||
// UUID directory as a NEW directory, both relative to an open trusted descriptor.
|
||||
// The module rejects a pre-existing target, a symlink, a non-directory, a wrong
|
||||
// owner, and a wrong mode. It uses no recursive delete. It re-checks the directory
|
||||
// with a no-symlink walk before it opens the terminal and again before it sends the
|
||||
// launch input, so a symlink swapped in after creation fails before the launch.
|
||||
// creates the session home directory with one `mkdir -p` command. The command
|
||||
// runs inside the sandbox, so it holds no authority over a host file and no
|
||||
// authority to call the Paperclip API. The sandbox contract in
|
||||
// `SANDBOX-REQUIREMENTS.md` treats a check that only inspects state inside the
|
||||
// sandbox as a non-boundary control, so this module keeps no owner check, no
|
||||
// mode check, and no link-type check for the session home.
|
||||
//
|
||||
// Dependency boundary: this provider plugin ships standalone (the workspace
|
||||
// excludes `packages/plugins/sandbox-providers/**`). So the module imports no
|
||||
|
|
@ -49,8 +44,6 @@
|
|||
// or OSC 8 handling; the login parser owns that handling.
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { sendPtyInputInChunks } from "./pty-chunked-input.js";
|
||||
|
||||
|
|
@ -85,9 +78,6 @@ const LOGIN_COMMAND_BY_KEY: Readonly<Record<LoginCommandKey, string>> = {
|
|||
codex: "codex login --device-auth",
|
||||
};
|
||||
|
||||
/** The octal mode string for a new session home directory. */
|
||||
const LOGIN_HOME_MODE = "700";
|
||||
|
||||
/** The fixed root for a login session home. */
|
||||
const LOGIN_SESSION_HOME_ROOT = "/tmp/paperclip-adapter-login";
|
||||
|
||||
|
|
@ -220,40 +210,16 @@ export interface DaytonaSandboxExec {
|
|||
): Promise<DaytonaExecResult>;
|
||||
}
|
||||
|
||||
/** One no-follow inspection of a filesystem path. */
|
||||
export interface LoginHomeInspection {
|
||||
/** True when the path exists (as any type). */
|
||||
exists: boolean;
|
||||
/** True when the path itself is a symbolic link. */
|
||||
isSymlink: boolean;
|
||||
/** True when the path itself is a directory. */
|
||||
isDirectory: boolean;
|
||||
/** The octal permission string, for example `700`. Empty when the path is absent. */
|
||||
mode: string;
|
||||
/** The owner user id, or null when the path is absent. */
|
||||
ownerUid: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The narrow filesystem surface the session home operations need. The production
|
||||
* surface runs commands on the sandbox; a unit test injects a fake. The inspection
|
||||
* and the creation walk each path component with a no-follow open, so a symlink at
|
||||
* any ancestor fails closed and a symlink at the target reports the link.
|
||||
* The narrow filesystem surface the session home operation needs. The production
|
||||
* surface runs a command on the sandbox; a unit test injects a fake.
|
||||
*/
|
||||
export interface DaytonaLoginHomeFs {
|
||||
/** Resolves the user id of the login user that runs the pseudo-terminal. */
|
||||
loginUserId(): Promise<number>;
|
||||
/**
|
||||
* Inspects `path` without following a symlink at the target or at any ancestor.
|
||||
* It rejects (throws) when an ancestor is a symlink or a non-directory.
|
||||
* Creates `path` as a directory, plus a missing parent directory. It does not
|
||||
* fail when the directory already exists.
|
||||
*/
|
||||
inspect(path: string): Promise<LoginHomeInspection>;
|
||||
/**
|
||||
* Creates `path` as a NEW directory with the octal `mode`. It creates the login
|
||||
* root ancestor if the root is absent. It fails when the target path exists or
|
||||
* when an ancestor is a symlink. It follows no composite pathname.
|
||||
*/
|
||||
createDirectory(path: string, mode: string): Promise<void>;
|
||||
createDirectory(path: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** The options for the Daytona login PTY session. */
|
||||
|
|
@ -276,11 +242,6 @@ const LOGIN_PTY_ROWS = 30;
|
|||
*/
|
||||
const PTY_COMMAND_TERMINATOR = "\r";
|
||||
|
||||
/** Normalizes an octal permission string to its numeric value for comparison. */
|
||||
function octalMode(value: string): number {
|
||||
return Number.parseInt(value, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revalidates the launch descriptor. It fails closed when the command key is
|
||||
* outside the closed set or the session home shape is wrong. A unit test that
|
||||
|
|
@ -296,59 +257,12 @@ function assertDescriptor(descriptor: LoginPtyLaunchDescriptor): void {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and validates the exact session home directory. It rejects a
|
||||
* pre-existing target (including a symlink), creates the directory as a NEW
|
||||
* directory with mode 0700, then verifies the directory type, the no-symlink
|
||||
* state, the mode, and the login-user ownership. It uses no recursive delete.
|
||||
*/
|
||||
async function prepareSessionHome(fs: DaytonaLoginHomeFs, sessionHome: string): Promise<void> {
|
||||
const loginUid = await fs.loginUserId();
|
||||
|
||||
// Reject a pre-existing target. A no-follow inspection reports a symlink, a
|
||||
// file, or a directory as present, so any pre-existing target fails here.
|
||||
const before = await fs.inspect(sessionHome);
|
||||
if (before.exists) {
|
||||
throw new Error(LOGIN_PTY_HOME_REJECTED);
|
||||
}
|
||||
|
||||
// Create the exact directory as a NEW directory with mode 0700. The create
|
||||
// fails when the path exists, so the create never follows a symlink.
|
||||
await fs.createDirectory(sessionHome, LOGIN_HOME_MODE);
|
||||
|
||||
// Verify the created directory. Reject a symlink, a non-directory, a wrong
|
||||
// owner, and a wrong mode. Do not delete on a rejection; the sandbox is
|
||||
// ephemeral and the provider uses no recursive delete.
|
||||
const after = await fs.inspect(sessionHome);
|
||||
if (
|
||||
!after.exists ||
|
||||
after.isSymlink ||
|
||||
!after.isDirectory ||
|
||||
after.ownerUid !== loginUid ||
|
||||
octalMode(after.mode) !== octalMode(LOGIN_HOME_MODE)
|
||||
) {
|
||||
throw new Error(LOGIN_PTY_HOME_REJECTED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-checks the directory with a no-symlink check. It rejects a symlink and a
|
||||
* non-directory, so a symlink swapped in after creation fails before the launch.
|
||||
*/
|
||||
async function assertHomeStillSafe(fs: DaytonaLoginHomeFs, sessionHome: string): Promise<void> {
|
||||
const now = await fs.inspect(sessionHome);
|
||||
if (!now.exists || now.isSymlink || !now.isDirectory) {
|
||||
throw new Error(LOGIN_PTY_HOME_REJECTED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a Daytona PTY session for `descriptor` and returns it as a
|
||||
* {@link LoginPtySession}. The function revalidates the descriptor and the home
|
||||
* shape, creates and validates the session home, opens a real pseudo-terminal,
|
||||
* re-checks the directory before the launch, composes the safe launch line, and
|
||||
* sends it. The session streams the raw terminal output, delivers delayed input,
|
||||
* and stops the child.
|
||||
* shape, creates the session home, opens a real pseudo-terminal, composes the
|
||||
* safe launch line, and sends it. The session streams the raw terminal output,
|
||||
* delivers delayed input, and stops the child.
|
||||
*
|
||||
* The function decodes the terminal bytes as a UTF-8 stream, so a multibyte
|
||||
* character that splits across two output chunks stays whole. It buffers the
|
||||
|
|
@ -361,10 +275,9 @@ export async function openDaytonaLoginPtySession(
|
|||
options?: DaytonaLoginPtyOptions,
|
||||
): Promise<LoginPtySession> {
|
||||
assertDescriptor(descriptor);
|
||||
// Create and validate the session home before the terminal opens.
|
||||
await prepareSessionHome(fs, descriptor.sessionHome);
|
||||
// Re-check the directory with a no-symlink check before `createPty`.
|
||||
await assertHomeStillSafe(fs, descriptor.sessionHome);
|
||||
// Create the session home. `mkdir -p` succeeds when the directory already
|
||||
// exists, so a repeat login for the same home needs no extra check.
|
||||
await fs.createDirectory(descriptor.sessionHome);
|
||||
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let listener: ((chunk: string) => void) | null = null;
|
||||
|
|
@ -387,9 +300,6 @@ export async function openDaytonaLoginPtySession(
|
|||
});
|
||||
|
||||
await handle.waitForConnection();
|
||||
// Re-check the directory with a no-symlink check immediately before the launch
|
||||
// input, so a symlink swapped in after creation fails before `sendInput`.
|
||||
await assertHomeStillSafe(fs, descriptor.sessionHome);
|
||||
// Replace the interactive shell with the login command, so the pseudo-terminal
|
||||
// runs the command directly. The runner then writes the delayed browser code
|
||||
// to the command, not to a shell.
|
||||
|
|
@ -434,63 +344,15 @@ export async function openDaytonaLoginPtySession(
|
|||
}
|
||||
|
||||
/**
|
||||
* The inspection helper source. The provider reads it from its own script file and
|
||||
* runs it on the sandbox as `<login-profile-preamble> && node -e <script> <path>`.
|
||||
* The preamble sources the login profiles first, so `node` resolves on an image
|
||||
* that exposes node only through a login profile. The sandbox already runs node
|
||||
* for the Paperclip bridge, so the helper needs no extra runtime. The helper
|
||||
* source lives in `scripts/login-home-inspect.cjs`, so no large script stays as a
|
||||
* string literal in this module. The helper opens the filesystem root, then walks
|
||||
* each ancestor of the final path component with a no-follow, directory-only open,
|
||||
* and reads the final component with `lstat`.
|
||||
*
|
||||
* The helper prints one line and sets one exit code:
|
||||
* - a missing ancestor or a missing final component prints `ABSENT` and exits 0;
|
||||
* - an existing final component prints `<type>|<octal-mode>|<uid>` and exits 0,
|
||||
* where `<type>` is `symlink`, `directory`, or `other`;
|
||||
* - a symlink or a non-directory at any ancestor prints nothing and exits 3, so
|
||||
* the caller fails closed on an ancestor symlink.
|
||||
*/
|
||||
const LOGIN_HOME_INSPECT_SCRIPT = readFileSync(
|
||||
fileURLToPath(new URL("./scripts/login-home-inspect.cjs", import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
/**
|
||||
* The creation helper source. The provider reads it from its own script file and
|
||||
* runs it on the sandbox as
|
||||
* `<login-profile-preamble> && node -e <script> <path> <octal-mode>`. The preamble
|
||||
* sources the login profiles first, so `node` resolves on an image that exposes
|
||||
* node only through a login profile. The helper source lives in
|
||||
* `scripts/login-home-create.cjs`. The helper opens the filesystem
|
||||
* root, then walks each ancestor above the login root with a no-follow,
|
||||
* directory-only open. It creates the login root (the second-to-last component) if
|
||||
* the root is absent, then opens the root with a no-follow open, so a symlink at
|
||||
* the root fails closed. It creates the final component as a NEW directory relative
|
||||
* to the root descriptor, so a pre-existing target fails and the create never
|
||||
* follows a symlink. It exits 0 on success and non-zero on every failure.
|
||||
*
|
||||
* The no-follow open of the root after the create closes the swap window: an
|
||||
* attacker that replaces the root with a symlink between the create and the open
|
||||
* fails the open.
|
||||
*/
|
||||
const LOGIN_HOME_CREATE_SCRIPT = readFileSync(
|
||||
fileURLToPath(new URL("./scripts/login-home-create.cjs", import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
/**
|
||||
* The login-profile preamble for a node helper command. Daytona's
|
||||
* The login-profile preamble for a sandbox exec command. Daytona's
|
||||
* `executeCommand` runs the command in a non-login shell, so the shell does not
|
||||
* source `/etc/profile` on its own. The Daytona reference image puts `node` on
|
||||
* the PATH through `/etc/profile.d`, which only a login profile sources. The
|
||||
* login pseudo-terminal resolves the login command through the same profiles, so
|
||||
* the helper sources the login profiles first and a non-login shell then resolves
|
||||
* `node`. This keeps the helper runtime and the login PTY capability consistent:
|
||||
* an image that exposes `node` only through a login profile runs the helper. The
|
||||
* main exec path uses the same profile chain (see `buildLoginShellScript` in
|
||||
* `plugin.ts`). Each source line fails open (`|| true`), so a missing profile
|
||||
* does not fail the helper.
|
||||
* source `/etc/profile` on its own. The Daytona reference image puts a CLI on the
|
||||
* PATH through `/etc/profile.d`, which only a login profile sources. The login
|
||||
* pseudo-terminal resolves the login command through the same profiles, so this
|
||||
* preamble sources the login profiles first, and a non-login shell then resolves
|
||||
* the same CLI set. The main exec path uses the same profile chain (see
|
||||
* `buildLoginShellScript` in `plugin.ts`). Each source line fails open
|
||||
* (`|| true`), so a missing profile does not fail the command.
|
||||
*/
|
||||
const LOGIN_PROFILE_PREAMBLE = [
|
||||
"if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi",
|
||||
|
|
@ -500,69 +362,25 @@ const LOGIN_PROFILE_PREAMBLE = [
|
|||
].join(" && ");
|
||||
|
||||
/**
|
||||
* Composes a `node` helper command that runs after the login-profile preamble.
|
||||
* The preamble puts `node` on the PATH, then the `&&` chain runs `node -e` with
|
||||
* the already shell-encoded argument list. The `node` exit code becomes the
|
||||
* command exit code, and `2>/dev/null` suppresses only the node stderr. The
|
||||
* caller passes an argument string that is already shell-encoded.
|
||||
* Composes a sandbox exec command that runs after the login-profile preamble.
|
||||
* The preamble runs first, then the `&&` chain runs `command`.
|
||||
*/
|
||||
function composeNodeHelperCommand(encodedArgs: string): string {
|
||||
return `${LOGIN_PROFILE_PREAMBLE} && node -e ${encodedArgs} 2>/dev/null`;
|
||||
function composeLoginProfileCommand(command: string): string {
|
||||
return `${LOGIN_PROFILE_PREAMBLE} && ${command}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link DaytonaLoginHomeFs} bound to a Daytona `process`. It runs a
|
||||
* fixed node helper on the sandbox to inspect and create the session home. Each
|
||||
* helper opens the filesystem root, then walks each path component with a
|
||||
* no-follow, directory-only open, so a symlink at any ancestor fails closed. The
|
||||
* inspection reads the final component with `lstat`, so a symlink at the target
|
||||
* reports the link. The creation creates the login root if absent and the session
|
||||
* home as a NEW directory, both relative to an open trusted descriptor, so no
|
||||
* operation follows a composite pathname. The real filesystem operations land
|
||||
* against a live sandbox; a unit test injects a fake surface instead.
|
||||
* Creates a {@link DaytonaLoginHomeFs} bound to a Daytona `process`. It runs one
|
||||
* `mkdir -p` command on the sandbox to create the session home. The command runs
|
||||
* inside the sandbox, so it holds no authority over a host file and no authority
|
||||
* to call the Paperclip API. The real command lands against a live sandbox; a
|
||||
* unit test injects a fake surface instead.
|
||||
*/
|
||||
export function createDaytonaLoginHomeFs(exec: DaytonaSandboxExec): DaytonaLoginHomeFs {
|
||||
return {
|
||||
async loginUserId(): Promise<number> {
|
||||
const out = await exec.executeCommand("id -u");
|
||||
const uid = Number.parseInt((out.result ?? "").trim(), 10);
|
||||
if (!Number.isInteger(uid)) {
|
||||
throw new Error(LOGIN_PTY_HOME_REJECTED);
|
||||
}
|
||||
return uid;
|
||||
},
|
||||
async inspect(path: string): Promise<LoginHomeInspection> {
|
||||
// Read the file type, the octal mode, and the owner uid with a descriptor
|
||||
// relative no-follow walk. A non-zero exit means a symlink or a non-directory
|
||||
// at an ancestor, so the read fails closed. An `ABSENT` line means the target
|
||||
// does not exist yet, which is the safe precondition for a create.
|
||||
const script = encodePosixShellArg(LOGIN_HOME_INSPECT_SCRIPT);
|
||||
async createDirectory(path: string): Promise<void> {
|
||||
const encodedPath = encodePosixShellArg(path);
|
||||
const out = await exec.executeCommand(composeNodeHelperCommand(`${script} ${encodedPath}`));
|
||||
if ((out.exitCode ?? 0) !== 0) {
|
||||
throw new Error(LOGIN_PTY_HOME_REJECTED);
|
||||
}
|
||||
const text = (out.result ?? "").trim();
|
||||
if (text.length === 0 || text === "ABSENT") {
|
||||
return { exists: false, isSymlink: false, isDirectory: false, mode: "", ownerUid: null };
|
||||
}
|
||||
const [fileType = "", mode = "", ownerText = ""] = text.split("|");
|
||||
const ownerUid = Number.parseInt(ownerText, 10);
|
||||
return {
|
||||
exists: true,
|
||||
isSymlink: fileType === "symlink",
|
||||
isDirectory: fileType === "directory",
|
||||
mode,
|
||||
ownerUid: Number.isInteger(ownerUid) ? ownerUid : null,
|
||||
};
|
||||
},
|
||||
async createDirectory(path: string, mode: string): Promise<void> {
|
||||
const script = encodePosixShellArg(LOGIN_HOME_CREATE_SCRIPT);
|
||||
const encodedPath = encodePosixShellArg(path);
|
||||
const encodedMode = encodePosixShellArg(mode);
|
||||
const out = await exec.executeCommand(
|
||||
composeNodeHelperCommand(`${script} ${encodedPath} ${encodedMode}`),
|
||||
);
|
||||
const out = await exec.executeCommand(composeLoginProfileCommand(`mkdir -p ${encodedPath}`));
|
||||
if ((out.exitCode ?? 0) !== 0) {
|
||||
throw new Error(LOGIN_PTY_HOME_REJECTED);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2739,11 +2739,11 @@ const plugin = definePlugin({
|
|||
},
|
||||
|
||||
// Open one live login pseudo-terminal. Resolve the cached sandbox by the
|
||||
// provider lease id, revalidate the host launch descriptor and the session
|
||||
// home, create and validate the session home, run the fixed login command on a
|
||||
// real pseudo-terminal, and register the session under the host route id. Stream
|
||||
// the raw output and the exit through `ctx.loginPty`, bound to the returned
|
||||
// worker session id. Fail closed when no cached sandbox matches the lease.
|
||||
// provider lease id, revalidate the host launch descriptor, create the session
|
||||
// home with one `mkdir -p` command, run the fixed login command on a real
|
||||
// pseudo-terminal, and register the session under the host route id. Stream the
|
||||
// raw output and the exit through `ctx.loginPty`, bound to the returned worker
|
||||
// session id. Fail closed when no cached sandbox matches the lease.
|
||||
async onLoginPtyOpen(params) {
|
||||
const sandbox = await sandboxHandleCache.findByProviderLeaseId(params.providerLeaseId);
|
||||
if (!sandbox) {
|
||||
|
|
@ -2751,9 +2751,7 @@ const plugin = definePlugin({
|
|||
"Daytona login pseudo-terminal: no cached sandbox resolves the provider lease.",
|
||||
);
|
||||
}
|
||||
const homeFs = createDaytonaLoginHomeFs(
|
||||
sandbox.process as unknown as DaytonaSandboxExec,
|
||||
);
|
||||
const homeFs = createDaytonaLoginHomeFs(sandbox.process as unknown as DaytonaSandboxExec);
|
||||
const session = await openLoginPtySession(
|
||||
sandbox.process as unknown as DaytonaPtyProcess,
|
||||
homeFs,
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
// The session-home creation helper. The provider runs it in the sandbox as
|
||||
// `node -e <this file> <path> <octal-mode>`. The sandbox already runs node for the
|
||||
// Paperclip bridge, so the helper needs no extra runtime.
|
||||
//
|
||||
// The helper opens the filesystem root, then walks each ancestor above the login
|
||||
// root with a no-follow, directory-only open. Node has no `openat`, so the helper
|
||||
// opens each next component through `/proc/self/fd/<dfd>/<component>`: the kernel
|
||||
// resolves the magic descriptor link, then applies `O_NOFOLLOW` to the final
|
||||
// component. It creates the login root (the second-to-last component) if the root
|
||||
// is absent, then opens the root with a no-follow open, so a symlink at the root
|
||||
// fails closed. It creates the final component as a NEW directory relative to the
|
||||
// root descriptor, so a pre-existing target fails and the create never follows a
|
||||
// symlink. It exits 0 on success and non-zero on every failure.
|
||||
//
|
||||
// The no-follow open of the root after the create closes the swap window: an
|
||||
// attacker that replaces the root with a symlink between the create and the open
|
||||
// fails the open.
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
|
||||
const path = process.argv[1];
|
||||
const mode = Number.parseInt(process.argv[2], 8);
|
||||
if (typeof path !== "string" || !path.startsWith("/")) process.exit(1);
|
||||
if (!Number.isInteger(mode)) process.exit(1);
|
||||
const parts = path.split("/").filter((component) => component.length > 0);
|
||||
if (parts.length < 2) process.exit(1);
|
||||
|
||||
let dfd;
|
||||
try {
|
||||
dfd = fs.openSync("/", fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
for (const part of parts.slice(0, -2)) {
|
||||
try {
|
||||
dfd = fs.openSync(
|
||||
`/proc/self/fd/${dfd}/${part}`,
|
||||
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
|
||||
);
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const root = parts[parts.length - 2];
|
||||
try {
|
||||
fs.mkdirSync(`/proc/self/fd/${dfd}/${root}`, { mode: 0o700 });
|
||||
} catch (error) {
|
||||
if (!error || error.code !== "EEXIST") process.exit(1);
|
||||
}
|
||||
|
||||
let rfd;
|
||||
try {
|
||||
rfd = fs.openSync(
|
||||
`/proc/self/fd/${dfd}/${root}`,
|
||||
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
|
||||
);
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(`/proc/self/fd/${rfd}/${parts[parts.length - 1]}`, { mode });
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
// The session-home inspection helper. The provider runs it in the sandbox as
|
||||
// `node -e <this file> <path>`. The sandbox already runs node for the Paperclip
|
||||
// bridge, so the helper needs no extra runtime.
|
||||
//
|
||||
// The helper opens the filesystem root, then walks each ancestor of the final
|
||||
// path component with a no-follow, directory-only open. Node has no `openat`, so
|
||||
// the helper opens each next component through `/proc/self/fd/<dfd>/<component>`:
|
||||
// the kernel resolves the magic descriptor link, then applies `O_NOFOLLOW` to the
|
||||
// final component. This binds each open to the descriptor inode, so a symlink at
|
||||
// any ancestor fails closed. The helper reads the final component with `lstat`, so
|
||||
// a symlink at the target reports the link, not the target.
|
||||
//
|
||||
// The helper prints one line and sets one exit code:
|
||||
// - a missing ancestor or a missing final component prints `ABSENT` and exits 0;
|
||||
// - an existing final component prints `<type>|<octal-mode>|<uid>` and exits 0,
|
||||
// where `<type>` is `symlink`, `directory`, or `other`;
|
||||
// - a symlink or a non-directory at any ancestor prints nothing and exits 3, so
|
||||
// the caller fails closed on an ancestor symlink.
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
|
||||
const path = process.argv[1];
|
||||
if (typeof path !== "string" || !path.startsWith("/")) process.exit(3);
|
||||
const parts = path.split("/").filter((component) => component.length > 0);
|
||||
if (parts.length === 0) process.exit(3);
|
||||
|
||||
function absent() {
|
||||
process.stdout.write("ABSENT");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let dfd;
|
||||
try {
|
||||
dfd = fs.openSync("/", fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
|
||||
} catch {
|
||||
process.exit(3);
|
||||
}
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
try {
|
||||
dfd = fs.openSync(
|
||||
`/proc/self/fd/${dfd}/${part}`,
|
||||
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error && error.code === "ENOENT") absent();
|
||||
process.exit(3);
|
||||
}
|
||||
}
|
||||
|
||||
const last = parts[parts.length - 1];
|
||||
let st;
|
||||
try {
|
||||
st = fs.lstatSync(`/proc/self/fd/${dfd}/${last}`);
|
||||
} catch (error) {
|
||||
if (error && error.code === "ENOENT") absent();
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
let fileType;
|
||||
if (st.isSymbolicLink()) fileType = "symlink";
|
||||
else if (st.isDirectory()) fileType = "directory";
|
||||
else fileType = "other";
|
||||
process.stdout.write(`${fileType}|${(st.mode & 0o7777).toString(8)}|${st.uid}`);
|
||||
process.exit(0);
|
||||
Loading…
Reference in New Issue