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:
Nicky Leach 2026-08-27 15:54:48 -07:00 committed by GitHub
parent 3d9be6f7fe
commit 17ebcc65b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 87 additions and 654 deletions

View File

@ -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",

View File

@ -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);
});
});

View File

@ -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);
}

View File

@ -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,

View File

@ -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);

View File

@ -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);