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
2 changes: 1 addition & 1 deletion src/daemon/__tests__/request-lock-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ test('rejects existing-session selector conflicts under request lock policy', ()
},
ref(IOS_SESSION),
),
/--serial=emulator-5554/i,
/Session "qa-ios" is already bound to apple device "iPhone 16" \(SIM-001\), but snapshot selected --serial=emulator-5554/i,
);
});

Expand Down
4 changes: 4 additions & 0 deletions src/daemon/__tests__/request-router-open.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,10 @@ test('open stores admitted lease metadata on the session', async () => {
);

expect(response.ok).toBe(true);
expect(sessionStore.get('tenant-a:default')?.sessionScope).toEqual({
kind: 'tenant',
id: 'tenant-a',
});
expect(sessionStore.get('tenant-a:default')?.lease).toEqual({
leaseId: lease.leaseId,
tenantId: 'tenant-a',
Expand Down
6 changes: 5 additions & 1 deletion src/daemon/__tests__/request-router-session-address.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ test('lock-policy conflict on an implicit session names its store key, not "defa
expect(response.ok).toBe(false);
if (response.ok) return;
expect(response.error.code).toBe('INVALID_ARGS');
expect(response.error.message).toContain(`session "${address}"`);
expect(response.error.message).toContain(`Session "${address}"`);
expect(response.error.details?.session).toBe(address);
expectAddressableRecovery(errorHint(response), address);
});
Expand All @@ -221,4 +221,8 @@ test('session list reports the address an implicit session answers to alongside
expect(sessions?.[0]?.name).toBe('default');
expect(sessions?.[0]?.address).toBe(address);
expect(sessions?.[0]?.address).toMatch(SCOPED_ADDRESS_PATTERN);
expect(sessionStore.get(address)?.sessionScope).toEqual({
kind: 'cwd',
id: address.split(':')[1],
});
});
36 changes: 34 additions & 2 deletions src/daemon/__tests__/session-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { SessionStore } from '../session-store.ts';
import { resolveEffectiveSessionName } from '../session-routing.ts';
import type { SessionState } from '../types.ts';
import { resolveEffectiveSessionName, resolveSessionScope } from '../session-routing.ts';
import type { DaemonRequest, SessionState } from '../types.ts';
import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts';

function makeSession(name: string): SessionState {
Expand Down Expand Up @@ -111,3 +111,35 @@ test('keeps explicitly configured default session global', (t) => {

assert.equal(resolved, 'default');
});

test('classifies every persisted session provenance without parsing its address', (t) => {
const cwd = mkdtempForTestSync('agent-device-cwd-scope-');
t.onTestFinished(() => {
fs.rmSync(cwd, { recursive: true, force: true });
});
const request: DaemonRequest = {
token: 't',
session: 'default',
command: 'open',
positionals: ['com.example.app'],
flags: {},
};

const cwdScope = resolveSessionScope({ ...request, session: 'default', meta: { cwd } });
assert.equal(cwdScope.kind, 'cwd');
if (cwdScope.kind === 'cwd') assert.match(cwdScope.id, /^[a-f0-9]{16}$/);
assert.deepEqual(resolveSessionScope({ ...request, session: 'tenant-a:qa' }), {
kind: 'named-local',
});
assert.deepEqual(resolveSessionScope({ ...request, session: 'default' }), {
kind: 'global-default',
});
assert.deepEqual(
resolveSessionScope({
...request,
session: 'tenant-a:default',
meta: { tenantId: 'tenant-a', sessionIsolation: 'tenant' },
}),
{ kind: 'tenant', id: 'tenant-a' },
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { handleSessionInventoryCommands } from '../session-inventory.ts';
import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts';
import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts';
import { IOS_SIMULATOR } from '../../../__tests__/test-utils/device-fixtures.ts';
import { resolveImplicitSessionScope } from '../../session-routing.ts';
import { tenantScopedSessionName } from '../../session-tenant-scope.ts';

// A session opened without an explicit --session is NAMED `default` and STORED under
// `cwd:<hash>:default`. `session list` resolved its paths from the name, so it answered with
Expand Down Expand Up @@ -39,6 +41,103 @@ async function runSessionList(): Promise<DaemonResponse | null> {
});
}

test('session list returns the caller cwd and local named sessions without crossing cwd or tenant ownership', async () => {
const sessionStore = makeSessionStore('agent-device-inventory-scoped-');
const req: DaemonRequest = {
token: 't',
session: 'default',
command: 'session_list',
positionals: [],
flags: {},
meta: { cwd: '/tmp/shop-app' },
};
const callerScope = resolveImplicitSessionScope(req)!;
const callerSessionAddress = `cwd:${callerScope.id}:default`;
sessionStore.set(callerSessionAddress, {
...scopedSession(),
sessionScope: callerScope,
});
sessionStore.set('qa-cart-integrity', {
...scopedSession(),
name: 'qa-cart-integrity',
sessionScope: { kind: 'named-local' },
});
sessionStore.set('tenant-a:qa', {
...scopedSession(),
name: 'tenant-a:qa',
sessionScope: { kind: 'named-local' },
});
sessionStore.set('default', {
...scopedSession(),
sessionScope: { kind: 'global-default' },
});
sessionStore.set('cwd:other:default', {
...scopedSession(),
sessionScope: { kind: 'cwd', id: 'other' },
});
const tenantSessionAddress = tenantScopedSessionName('tenant-a', 'remote-recording');
sessionStore.set(tenantSessionAddress, {
...scopedSession(),
name: tenantSessionAddress,
sessionScope: { kind: 'tenant', id: 'tenant-a' },
});

const response = await handleSessionInventoryCommands({
req,
sessionName: callerSessionAddress,
sessionStore,
});

expect(response?.ok).toBe(true);
if (!response?.ok) return;
const sessions = response.data?.sessions as { name: string; address: string }[];
expect(sessions).toEqual([
expect.objectContaining({ name: 'default', address: callerSessionAddress }),
expect.objectContaining({ name: 'qa-cart-integrity', address: 'qa-cart-integrity' }),
expect.objectContaining({ name: 'tenant-a:qa', address: 'tenant-a:qa' }),
]);
});

test('session list returns only sessions owned by the requesting tenant', async () => {
const sessionStore = makeSessionStore('agent-device-inventory-tenant-');
for (const tenantId of ['tenant-a', 'tenant-b']) {
const address = tenantScopedSessionName(tenantId, 'default');
sessionStore.set(address, {
...scopedSession(),
name: address,
sessionScope: { kind: 'tenant', id: tenantId },
});
}
for (const address of ['qa-cart-integrity', 'tenant-a:qa']) {
sessionStore.set(address, {
...scopedSession(),
name: address,
sessionScope: { kind: 'named-local' },
});
}
const req: DaemonRequest = {
token: 't',
session: tenantScopedSessionName('tenant-a', 'default'),
command: 'session_list',
positionals: [],
flags: {},
meta: { tenantId: 'tenant-a', sessionIsolation: 'tenant' },
};

const response = await handleSessionInventoryCommands({
req,
sessionName: req.session,
sessionStore,
});

expect(response?.ok).toBe(true);
if (!response?.ok) return;
const sessions = response.data?.sessions as { address: string }[];
expect(sessions).toEqual([
expect.objectContaining({ address: tenantScopedSessionName('tenant-a', 'default') }),
]);
});

test('session list resolves a cwd-scoped session directory from its store key', async () => {
const response = await runSessionList();

Expand Down
1 change: 1 addition & 0 deletions src/daemon/handlers/__tests__/session-open-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ function reopen(existingSession: ReturnType<typeof makeIosSession>) {
return buildNextOpenSession({
existingSession,
sessionName: existingSession.name,
sessionScope: existingSession.sessionScope ?? { kind: 'named-local' },
device: IOS_SIMULATOR,
surface: 'app',
appBundleId: 'com.example.other',
Expand Down
1 change: 1 addition & 0 deletions src/daemon/handlers/__tests__/session-test-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export function makeSessionStore(): SessionStore {
export function makeSession(name: string, device: SessionState['device']): SessionState {
return {
name,
sessionScope: { kind: 'named-local' },
device,
createdAt: Date.now(),
actions: [],
Expand Down
4 changes: 2 additions & 2 deletions src/daemon/handlers/record-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
screenRecordingDurableResource,
} from '../screen-recording-session-resource.ts';
import { createScreenRecordingRecoveryControl } from '../screen-recording-resource-recovery.ts';
import { resolveImplicitSessionScope } from '../session-routing.ts';
import { resolveSessionScope } from '../session-routing.ts';
import type { SessionStore } from '../session-store.ts';
import type { BindDeviceRuntime, BindExactDeviceRuntime } from '../request-runtime-binding.ts';
import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts';
Expand Down Expand Up @@ -281,7 +281,7 @@ function createRecordOnlySession(
): SessionState {
return {
name: params.sessionName,
sessionScope: resolveImplicitSessionScope(params.req),
sessionScope: resolveSessionScope(params.req),
device,
createdAt: Date.now(),
recordOnlySession: true,
Expand Down
6 changes: 3 additions & 3 deletions src/daemon/handlers/session-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
selectorTargetsSessionDevice,
} from './session-device-utils.ts';
import { errorResponse } from './response.ts';
import { resolveImplicitSessionScope, sessionMatchesScope } from '../session-routing.ts';
import { resolveSessionScope, sessionMatchesInventoryScope } from '../session-routing.ts';
import type {
BoundDeviceRuntime,
RuntimeFacts,
Expand Down Expand Up @@ -77,13 +77,13 @@ function sessionListInventoryResponse(
req: DaemonRequest,
sessionStore: SessionStore,
): DaemonResponse {
const scope = resolveImplicitSessionScope(req);
const scope = resolveSessionScope(req);
return {
ok: true,
data: {
sessions: sessionStore
.listRefs()
.filter((ref) => sessionMatchesScope(ref.session, scope))
.filter((ref) => sessionMatchesInventoryScope(ref.session, scope))
.map((ref) => publicSessionInfo(ref, sessionStore)),
},
};
Expand Down
14 changes: 10 additions & 4 deletions src/daemon/handlers/session-open-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ import {
import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime';
import type { SessionSurface } from '@agent-device/contracts/session';
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { DaemonRequest, DaemonResponse, SessionRef, SessionState } from '../types.ts';
import type {
DaemonRequest,
DaemonResponse,
SessionRef,
SessionScope,
SessionState,
} from '../types.ts';
import {
abortAuthoringOnSecondOpen,
armAuthoringOnOpen,
Expand Down Expand Up @@ -40,7 +46,7 @@ import { errorResponse } from './response.ts';
import { buildSessionRecoveryHint } from '../session-recovery-hints.ts';
import {
isImplicitSessionScopeConflict,
resolveImplicitSessionScope,
resolveSessionScope,
resolvePublicSessionName,
} from '../session-routing.ts';
import { resolveSessionLeaseForRequest } from '../lease-lifecycle.ts';
Expand Down Expand Up @@ -79,8 +85,8 @@ export type RuntimeHintClearOperation = BoundDeviceRuntime<
typeof openApplicationWithRuntimeHintClearUse
>['operations']['clearRuntimeHints'];

function resolveOpenSessionScope(req: DaemonRequest): SessionState['sessionScope'] | undefined {
return req.internal?.resolvedSessionScope ?? resolveImplicitSessionScope(req);
function resolveOpenSessionScope(req: DaemonRequest): SessionScope {
return req.internal?.resolvedSessionScope ?? resolveSessionScope(req);
}

function applyOrdinaryScriptRecordingOpenOutcome(params: {
Expand Down
4 changes: 2 additions & 2 deletions src/daemon/handlers/session-open-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
publicPlatformString,
type DeviceInfo,
} from '@agent-device/kernel/device';
import type { SessionRuntimeHints, SessionState } from '../types.ts';
import type { SessionRuntimeHints, SessionScope, SessionState } from '../types.ts';
import { successText } from '@agent-device/kernel/success-text';
import type { StartupPerfSample } from './session-startup-metrics.ts';
import type { DeviceSelectionResult } from '../../core/device-selection-resolver.ts';
Expand Down Expand Up @@ -92,7 +92,7 @@ function selectionResponseData(
export function buildNextOpenSession(params: {
existingSession?: SessionState;
sessionName: string;
sessionScope?: SessionState['sessionScope'];
sessionScope: SessionScope;
device: DeviceInfo;
surface: SessionSurface;
appBundleId?: string;
Expand Down
5 changes: 2 additions & 3 deletions src/daemon/handlers/session-replay-action-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { buildDisplayPositionals } from '../session-event-action.ts';
import { appendReplayTraceEvent } from './session-replay-trace.ts';
import { inferFillText } from '../action-utils.ts';
import { readRecordedInputVariableName } from '@agent-device/ad-script';
import { resolveImplicitSessionScope } from '../session-routing.ts';
import { resolveSessionScope } from '../session-routing.ts';

type ReplayBaseRequest = Omit<DaemonRequest, 'command' | 'positionals'>;

Expand Down Expand Up @@ -155,8 +155,7 @@ async function invokeResolvedReplayAction(params: {
replayPlanStep: true,
...(resolved.command === 'open'
? {
resolvedSessionScope:
req.internal?.resolvedSessionScope ?? resolveImplicitSessionScope(req),
resolvedSessionScope: req.internal?.resolvedSessionScope ?? resolveSessionScope(req),
}
: {}),
},
Expand Down
6 changes: 4 additions & 2 deletions src/daemon/handlers/snapshot-session.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { resolveTargetDevice } from '../../core/dispatch-resolve.ts';
import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup';
import type { DaemonRequest, SessionState } from '../types.ts';
import type { DaemonRequest, SessionScope, SessionState } from '../types.ts';
import { ensureDeviceReady } from '../device-ready.ts';
import { SessionStore } from '../session-store.ts';

Expand Down Expand Up @@ -49,11 +49,12 @@ export function recordIfSession(
export function buildSnapshotSession(params: {
session: SessionState | undefined;
sessionName: string;
sessionScope: SessionScope;
device: SessionState['device'];
snapshot: SessionState['snapshot'];
appBundleId?: string;
}): SessionState {
const { session, sessionName, device, snapshot, appBundleId } = params;
const { session, sessionName, sessionScope, device, snapshot, appBundleId } = params;
if (session) {
return {
...session,
Expand All @@ -64,6 +65,7 @@ export function buildSnapshotSession(params: {
}
return {
name: sessionName,
sessionScope,
device,
createdAt: Date.now(),
appBundleId,
Expand Down
4 changes: 2 additions & 2 deletions src/daemon/request-lock-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,8 @@ function buildLockPolicyConflictMessage(
const conflictList = conflicts.map(formatSessionSelectorConflict).join(', ');
if (existingRef) {
return (
`${req.command} is already bound to session "${existingRef.address}" on ${describeSessionDevice(existingRef.session)}, ` +
`but this request selected ${conflictList}.`
`Session "${existingRef.address}" is already bound to ${describeSessionDevice(existingRef.session)}, ` +
`but ${req.command} selected ${conflictList}.`
);
}
const lockPlatform = req.meta?.lockPlatform;
Expand Down
Loading
Loading