Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/host-kit/src/session-paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
// oxlint-disable-next-line no-restricted-imports -- asserts a path under os.homedir
import os from 'node:os';
import path from 'node:path';
import { expandSessionPath } from './session-paths.ts';

test('expandSessionPath resolves tilde, relative-with-cwd, and absolute paths', () => {
const homePath = expandSessionPath('~/flows/replay.ad');
assert.equal(homePath, path.join(os.homedir(), 'flows', 'replay.ad'));

const relativePath = expandSessionPath('workflows/replay.ad', '/tmp/agent-device-cwd');
assert.equal(relativePath, path.resolve('/tmp/agent-device-cwd', 'workflows/replay.ad'));

const absoluteInput = path.resolve('/tmp', 'agent-device-absolute.ad');
const absolutePath = expandSessionPath(absoluteInput, '/tmp/ignored-cwd');
assert.equal(absolutePath, absoluteInput);
});
29 changes: 29 additions & 0 deletions src/daemon/__tests__/session-artifact-paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import path from 'node:path';
import { AppError } from '@agent-device/kernel/errors';
import { resolveSessionDir } from '../session-artifact-paths.ts';
import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts';

test('resolveSessionDir keeps every session dir beneath the sessions dir', () => {
const sessionsDir = path.join(
mkdtempForTestSync('agent-device-tests'),
'agent-device-tests',
'sessions',
);
assert.equal(resolveSessionDir(sessionsDir, 'a/b:c d'), path.join(sessionsDir, 'a_b_c_d'));
// `.` and `..` survive `safeSessionName` unchanged, so without an explicit
// refusal `path.join` resolves them to the sessions dir itself and its parent
// (the daemon state dir): a remote caller's `--session ..` would then land
// app.log / runner.log / requests/*.ndjson outside the sessions tree.
for (const name of ['.', '..', '']) {
assert.throws(
() => resolveSessionDir(sessionsDir, name),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
/session name/i.test(error.message),
`expected resolveSessionDir(${JSON.stringify(name)}) to reject`,
);
}
});
40 changes: 0 additions & 40 deletions src/daemon/__tests__/session-store.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import fs from 'node:fs';
// oxlint-disable-next-line no-restricted-imports -- asserts a path under os.homedir
import os from 'node:os';
import path from 'node:path';
import { AppError } from '@agent-device/kernel/errors';
import { SessionStore } from '../session-store.ts';
import type { SessionState } from '../session-state.ts';
import { buildRequestFinishedEvent } from '@agent-device/session-journal/session-event-log';
Expand Down Expand Up @@ -96,19 +93,6 @@ function assertScriptMatches(script: string, patterns: RegExp[]): void {
}
}

test('expandHome resolves tilde, relative-with-cwd, and absolute paths', () => {
const homePath = SessionStore.expandHome('~/flows/replay.ad');
assert.equal(homePath.startsWith(os.homedir()), true);
assert.equal(homePath.endsWith(path.join('flows', 'replay.ad')), true);

const relativePath = SessionStore.expandHome('workflows/replay.ad', '/tmp/agent-device-cwd');
assert.equal(relativePath, path.resolve('/tmp/agent-device-cwd', 'workflows/replay.ad'));

const absoluteInput = path.resolve('/tmp', 'agent-device-absolute.ad');
const absolutePath = SessionStore.expandHome(absoluteInput, '/tmp/ignored-cwd');
assert.equal(absolutePath, absoluteInput);
});

test('defaultTracePath sanitizes session name', () => {
const store = new SessionStore(
path.join(mkdtempForTestSync('agent-device-tests'), 'agent-device-tests'),
Expand All @@ -119,30 +103,6 @@ test('defaultTracePath sanitizes session name', () => {
assert.match(tracePath, /\.trace\.log$/);
});

test('resolveSessionDir keeps every session dir beneath the sessions dir', () => {
const sessionsDir = path.join(
mkdtempForTestSync('agent-device-tests'),
'agent-device-tests',
'sessions',
);
const store = new SessionStore(sessionsDir);
assert.equal(store.resolveSessionDir('a/b:c d'), path.join(sessionsDir, 'a_b_c_d'));
// `.` and `..` survive `safeSessionName` unchanged, so without an explicit
// refusal `path.join` resolves them to the sessions dir itself and its parent
// (the daemon state dir): a remote caller's `--session ..` would then land
// app.log / runner.log / requests/*.ndjson outside the sessions tree.
for (const name of ['.', '..', '']) {
assert.throws(
() => store.resolveSessionDir(name),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
/session name/i.test(error.message),
`expected resolveSessionDir(${JSON.stringify(name)}) to reject`,
);
}
});

test('session lease metadata round-trips through the store', () => {
const { store, session } = makeFixture('agent-device-session-lease-');
session.lease = {
Expand Down
13 changes: 8 additions & 5 deletions src/daemon/device/device-claim-owner-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { createPlatformRuntimeGateway } from '../../platform-runtime.ts';
import { resolveDaemonPaths } from '../../daemon-resolution.ts';
import { createDeviceClaimReconciler } from './device-claim-reconciliation.ts';
import type { DeviceClaimReconciler } from './device-claims.ts';
import { SessionStore } from '../session-store.ts';
import {
resolveSessionDir,
resolveSessionAppLogPath,
resolveSessionAppLogPidPath,
} from '../session-artifact-paths.ts';

export type OwnerScopedClaimRecovery = {
reconcile: DeviceClaimReconciler;
Expand Down Expand Up @@ -49,17 +53,16 @@ function composeOwnerScopedClaimRecovery(
scope: PlatformRequestScope,
): OwnerScopedClaimRecovery {
const daemonPaths = resolveDaemonPaths(stateDir);
const sessionStore = new SessionStore(daemonPaths.sessionsDir);
const gateway = createPlatformRuntimeGateway({
sessionsDir: daemonPaths.sessionsDir,
ownedProcesses: createOwnedProcessRecordStore({
stateDir: daemonPaths.baseDir,
sessionsDir: daemonPaths.sessionsDir,
resolveSessionDir: (sessionId) => sessionStore.resolveSessionDir(sessionId),
resolveSessionDir: (sessionId) => resolveSessionDir(daemonPaths.sessionsDir, sessionId),
}),
resolveSessionArtifacts: (sessionId) => ({
outputPath: sessionStore.resolveAppLogPath(sessionId),
pidPath: sessionStore.resolveAppLogPidPath(sessionId),
outputPath: resolveSessionAppLogPath(daemonPaths.sessionsDir, sessionId),
pidPath: resolveSessionAppLogPidPath(daemonPaths.sessionsDir, sessionId),
}),
});
return {
Expand Down
7 changes: 4 additions & 3 deletions src/daemon/handlers/session-app-deployment.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { expandSessionPath } from '@agent-device/host-kit/session-paths';
import fs from 'node:fs';
import type { AppDeploymentResult } from '@agent-device/contracts/app-deployment-runtime';
import {
Expand All @@ -9,7 +10,7 @@ import { readNotificationPayload } from '../dispatch-payload.ts';
import { cleanupUploadedArtifact, prepareUploadedArtifact } from '../artifact-tracking.ts';
import { expireRefFrame } from '../ref-frame.ts';
import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts';
import { SessionStore } from '../session-store.ts';
import type { SessionStore } from '../session-store.ts';
import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts';
import type { SessionState } from '../session-state.ts';
import { resolvePayloadInput } from '../payload-input.ts';
Expand Down Expand Up @@ -62,7 +63,7 @@ export async function handleAppDeploymentCommand(params: {
try {
const appPath = uploadedArtifactId
? prepareUploadedArtifact(uploadedArtifactId, req.meta?.tenantId)
: SessionStore.expandHome(target.appPathInput);
: expandSessionPath(target.appPathInput);
if (!fs.existsSync(appPath)) {
return errorResponse('INVALID_ARGS', `App binary not found: ${appPath}`);
}
Expand Down Expand Up @@ -242,7 +243,7 @@ function resolvePushPayload(payloadArg: string, cwd?: string): string {
const resolved = resolvePayloadInput(payloadArg, {
subject: 'Push payload',
cwd,
expandPath: (value, currentCwd) => SessionStore.expandHome(value, currentCwd),
expandPath: (value, currentCwd) => expandSessionPath(value, currentCwd),
});
return resolved.kind === 'file' ? resolved.path : resolved.text;
}
9 changes: 4 additions & 5 deletions src/daemon/handlers/trace-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { expandSessionPath } from '@agent-device/host-kit/session-paths';
import fs from 'node:fs';
import path from 'node:path';
import type { TraceCommandResult } from '@agent-device/contracts/recording';
import { SessionStore } from '../session-store.ts';
import type { SessionStore } from '../session-store.ts';
import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts';
import type { SessionState } from '../session-state.ts';
import { recordSessionAction } from '../session-action-recorder.ts';
Expand Down Expand Up @@ -29,9 +30,7 @@ function startTrace(
session: SessionState,
): DaemonResponse {
if (session.trace) return errorResponse('INVALID_ARGS', 'trace already in progress');
const outPath = SessionStore.expandHome(
req.positionals?.[1] ?? sessionStore.defaultTracePath(session),
);
const outPath = expandSessionPath(req.positionals?.[1] ?? sessionStore.defaultTracePath(session));
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.appendFileSync(outPath, '');
session.trace = { outPath, startedAt: Date.now() };
Expand Down Expand Up @@ -72,7 +71,7 @@ function stopTrace(

function relocateTraceOutput(currentPath: string, requestedPath: string | undefined): string {
if (!requestedPath) return currentPath;
const resolved = SessionStore.expandHome(requestedPath);
const resolved = expandSessionPath(requestedPath);
fs.mkdirSync(path.dirname(resolved), { recursive: true });
if (fs.existsSync(currentPath)) fs.renameSync(currentPath, resolved);
else fs.appendFileSync(resolved, '');
Expand Down
4 changes: 2 additions & 2 deletions src/daemon/screenshot-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { expandSessionPath } from '@agent-device/host-kit/session-paths';
import type { CommandFlags } from '@agent-device/contracts/command';
import {
retiredScreenshotMaxSizeFlagError,
Expand Down Expand Up @@ -38,7 +39,6 @@ import {
type ScreenshotRuntimeBindings,
} from './screenshot-runtime-binding.ts';
import { setSessionSnapshot } from './session-snapshot.ts';
import { SessionStore } from './session-store.ts';
import type { DaemonRequest } from './daemon-request.ts';
import type { SessionState } from './session-state.ts';

Expand Down Expand Up @@ -321,7 +321,7 @@ function readScreenshotRequest(
const positionals = req.positionals ?? [];
const flags = req.flags ?? {};
const expand = (value: string | undefined) =>
value === undefined ? undefined : SessionStore.expandHome(value, req.meta?.cwd);
value === undefined ? undefined : expandSessionPath(value, req.meta?.cwd);
const positionalPath = expand(positionals[0]);
const outFlag = expand(flags.out);
return {
Expand Down
27 changes: 26 additions & 1 deletion src/daemon/session-artifact-paths.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import path from 'node:path';
import type { DiagnosticsRecordRef } from '@agent-device/kernel/errors';
import { safeSessionName } from '@agent-device/host-kit/session-paths';
import { AppError } from '@agent-device/kernel/errors';
import { isSafeSessionSegment, safeSessionName } from '@agent-device/host-kit/session-paths';

/** Path to session-scoped platform subprocess output, such as Apple runner xcodebuild logs. */
export function resolveSessionRunnerLogPath(sessionDir: string): string {
Expand Down Expand Up @@ -49,3 +50,27 @@ export function resolveRemoteRequestDiagnosticsPath(
ref.requestId,
);
}

/**
* The one place a session name becomes a directory, so the invariant that every
* session dir lies beneath `sessionsDir` is enforced here rather than by each
* caller: `.` and `..` survive `safeSessionName` and would resolve to the
* sessions dir itself or the daemon state dir above it.
*/
export function resolveSessionDir(sessionsDir: string, sessionName: string): string {
if (!isSafeSessionSegment(sessionName)) {
throw new AppError(
'INVALID_ARGS',
`Invalid session name ${JSON.stringify(sessionName)}: a session name cannot be empty, ".", or "..".`,
);
}
return path.join(sessionsDir, safeSessionName(sessionName));
}

export function resolveSessionAppLogPath(sessionsDir: string, address: string): string {
return path.join(resolveSessionDir(sessionsDir, address), 'app.log');
}

export function resolveSessionAppLogPidPath(sessionsDir: string, address: string): string {
return path.join(resolveSessionDir(sessionsDir, address), 'app-log.pid');
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { expandSessionPath } from '@agent-device/host-kit/session-paths';
import path from 'node:path';
import { type PerfCaptureAdmissionLedger } from '@agent-device/capture-kit/perf-capture-admission-ledger';
import {
Expand Down Expand Up @@ -28,7 +29,7 @@ import type {
BindDeviceRuntime,
InspectDeviceRuntimeFacts,
} from '../../request-runtime-binding.ts';
import { SessionStore } from '../../session-store.ts';
import type { SessionStore } from '../../session-store.ts';
import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts';
import type { SessionState } from '../../session-state.ts';
import { recordSessionAction } from '../../session-action-recorder.ts';
Expand Down Expand Up @@ -138,7 +139,7 @@ async function executeAdmittedPerfPlan(
appId: session.appBundleId,
kind: plan.request.kind,
outPath: plan.request.outPath
? SessionStore.expandHome(plan.request.outPath, params.req.meta?.cwd)
? expandSessionPath(plan.request.outPath, params.req.meta?.cwd)
: undefined,
artifactsDir: path.join(
params.sessionStore.ensureSessionDir(params.sessionName),
Expand Down Expand Up @@ -171,7 +172,7 @@ async function executeAdmittedPerfPlan(
const data = await runtime.operations.perfProfileReport({
appId: session.appBundleId,
kind: plan.request.kind,
tracePath: SessionStore.expandHome(tracePath, params.req.meta?.cwd),
tracePath: expandSessionPath(tracePath, params.req.meta?.cwd),
outPath,
template: plan.request.template ?? (last?.kind === 'xctrace' ? last.template : undefined),
profile: last,
Expand Down Expand Up @@ -257,7 +258,7 @@ async function stopPerfCapture(
const mismatchMessage = perfCaptureStopMismatch(snapshot, request);
if (mismatchMessage) return errorResponse('INVALID_ARGS', mismatchMessage);
if (request.outPath) {
capture.handle.setOutputPath(SessionStore.expandHome(request.outPath, params.req.meta?.cwd));
capture.handle.setOutputPath(expandSessionPath(request.outPath, params.req.meta?.cwd));
}
const completion = await finishLivePerfCapture({
intent: 'capture',
Expand Down Expand Up @@ -395,7 +396,7 @@ function resolveNativeOutPath(
requestedPath: string | undefined,
fallbackFileName: string,
): string {
if (requestedPath) return SessionStore.expandHome(requestedPath, params.req.meta?.cwd);
if (requestedPath) return expandSessionPath(requestedPath, params.req.meta?.cwd);
return path.join(
params.sessionStore.ensureSessionDir(params.sessionName),
`${timestampToken()}-${fallbackFileName}`,
Expand Down
32 changes: 8 additions & 24 deletions src/daemon/session-store.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import path from 'node:path';
import fs from 'node:fs';
import { AppError } from '@agent-device/kernel/errors';
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
import type { SessionRef, SessionRuntimeHints, SessionState } from './session-state.ts';
import { recordActionEntry, type RecordActionEntry } from './session-action-recorder.ts';
import { isSafeSessionSegment, safeSessionName } from '@agent-device/host-kit/session-paths';
import {
expandSessionPath,
isSafeSessionSegment,
safeSessionName,
} from '@agent-device/host-kit/session-paths';
resolveSessionDir,
resolveSessionAppLogPath,
resolveSessionAppLogPidPath,
} from './session-artifact-paths.ts';
import {
readRepairTombstoneFile,
resolveRepairTombstonePath,
Expand Down Expand Up @@ -380,20 +380,8 @@ export class SessionStore {
return path.join(this.sessionsDir, `${safeName}-${timestamp}.trace.log`);
}

/**
* The one place a session name becomes a directory, so the invariant that every
* session dir lies beneath `sessionsDir` is enforced here rather than by each
* caller: `.` and `..` survive `safeSessionName` and would resolve to the
* sessions dir itself or the daemon state dir above it.
*/
resolveSessionDir(sessionName: string): string {
if (!isSafeSessionSegment(sessionName)) {
throw new AppError(
'INVALID_ARGS',
`Invalid session name ${JSON.stringify(sessionName)}: a session name cannot be empty, ".", or "..".`,
);
}
return path.join(this.sessionsDir, safeSessionName(sessionName));
return resolveSessionDir(this.sessionsDir, sessionName);
}

// Daemon state dir (parent of the `sessions/` dir), matching daemonPaths.baseDir. Called via
Expand All @@ -411,21 +399,17 @@ export class SessionStore {

/** Path to session-scoped app log file. Agent can grep this for token-efficient debugging. */
resolveAppLogPath(sessionName: string): string {
return path.join(this.resolveSessionDir(sessionName), 'app.log');
return resolveSessionAppLogPath(this.sessionsDir, sessionName);
}

resolveAppLogPidPath(sessionName: string): string {
return path.join(this.resolveSessionDir(sessionName), 'app-log.pid');
return resolveSessionAppLogPidPath(this.sessionsDir, sessionName);
}

resolveEventLogPath(sessionName: string): string {
return resolveSessionEventLogPath(this.resolveSessionDir(sessionName));
}

static expandHome(filePath: string, cwd?: string): string {
return expandSessionPath(filePath, cwd);
}

/**
* Resolve the map key for a live session object. SessionState.name is the
* public session name, while the map key may include cwd/tenant isolation.
Expand Down
Loading