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
1 change: 1 addition & 0 deletions packages/provider-revyl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ customers; a partner integration gets one through Revyl's partner program.
npm install -g agent-device
agent-device plugins add @agent-device/revyl
export REVYL_API_KEY=your-api-key
export AGENT_DEVICE_SESSION=checkout-ios
agent-device connect revyl --platform ios --provider-app https://apps.example.com/MyApp-simulator.zip
agent-device open com.example.app
agent-device snapshot -i
Expand Down
2 changes: 1 addition & 1 deletion packages/provider-testmu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ npm install -g agent-device
agent-device plugins add @agent-device/testmu
export LT_USERNAME=your-username
export LT_ACCESS_KEY=your-access-key
agent-device connect testmu --platform ios --device "iPhone 16" --provider-os-version 18.0 --provider-app ./MyApp.zip
agent-device connect testmu --session checkout-ios --platform ios --device "iPhone 16" --provider-os-version 18.0 --provider-app ./MyApp.zip
```

See the [TestMu AI guide](https://agent-device.dev/docs/testmu) for setup and supported operations.
Expand Down
3 changes: 2 additions & 1 deletion packages/proxy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ server.listen(8080);
```

Clients connect with `agent-device connect proxy --daemon-base-url https://gateway.example.com/agent-device`
and `AGENT_DEVICE_DAEMON_AUTH_TOKEN` set to `clientToken`.
and `AGENT_DEVICE_DAEMON_AUTH_TOKEN` set to `clientToken`. Each agent sets its own `AGENT_DEVICE_SESSION`
(or passes `--session`): a command that names no session on a proxy connection is refused.

To mount the proxy in an existing `node:http` or `node:https` server, use
`createDaemonProxyRequestListener(createDaemonProxy(options))`.
Expand Down
119 changes: 102 additions & 17 deletions packages/remote/src/remote-connection-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,32 +284,25 @@
}
}

/**
Comment thread
thymikee marked this conversation as resolved.
Resolved
* The stored connection a command runs on, and the flags it contributes.
*
* `session` is the session the caller named (`--session`, `AGENT_DEVICE_SESSION`, or config), or
* `undefined` when it named none. Only a caller that named none falls back to the machine-wide
* active-session pointer, which names whichever connection was saved last, and it is refused a
* leased connection there (#3931).
*/
export function resolveRemoteConnectionDefaults(options: {
stateDir: string;
session: string;
session: string | undefined;
remoteConfig?: string;
cwd: string;
env: Record<string, string | undefined>;
allowActiveFallback?: boolean;
validateRemoteConfigHash?: boolean;
}): RemoteConnectionDefaults | null {
const validateRemoteConfigHash = options.validateRemoteConfigHash ?? true;
const expectedRemoteConfigPath = options.remoteConfig
? resolveRemoteConfigPath({
configPath: options.remoteConfig,
cwd: options.cwd,
env: options.env,
})
: undefined;
const state =
readRemoteConnectionState(options) ??
(options.allowActiveFallback
? readActiveConnectionState({ stateDir: options.stateDir })
: null);
const state = selectRemoteConnectionState(options);
if (!state) return null;
if (expectedRemoteConfigPath && state.remoteConfigPath !== expectedRemoteConfigPath) {
return null;
}
if (
validateRemoteConfigHash &&
hashRemoteConfigFile(state.remoteConfigPath) !== state.remoteConfigHash
Expand Down Expand Up @@ -345,6 +338,98 @@
};
}

function selectRemoteConnectionState(options: {
Comment thread
thymikee marked this conversation as resolved.
Resolved
Comment thread
thymikee marked this conversation as resolved.
Resolved
Comment thread
thymikee marked this conversation as resolved.
Resolved
stateDir: string;
session: string | undefined;
remoteConfig?: string;
cwd: string;
env: Record<string, string | undefined>;
}): RemoteConnectionState | null {
const state =
Comment thread
thymikee marked this conversation as resolved.
Resolved
Comment thread
thymikee marked this conversation as resolved.
Resolved
options.session === undefined
? (readRemoteConnectionState({ stateDir: options.stateDir, session: 'default' }) ??
readActiveConnectionState({ stateDir: options.stateDir }))
: readRemoteConnectionState({ stateDir: options.stateDir, session: options.session });
if (!state || !matchesRequestedRemoteConfig(state, options)) return null;
requireLeasedConnectionSession(state, options.session);
return state;
}

function matchesRequestedRemoteConfig(
state: RemoteConnectionState,
options: { remoteConfig?: string; cwd: string; env: Record<string, string | undefined> },
): boolean {
if (!options.remoteConfig) return true;
const expected = resolveRemoteConfigPath({
configPath: options.remoteConfig,
cwd: options.cwd,
env: options.env,
});
return state.remoteConfigPath === expected;
}

// The daemon accepts run ids of up to 128 characters.
const MAX_RUN_ID_LENGTH = 128;

/**
Comment thread
thymikee marked this conversation as resolved.
Resolved
Comment thread
thymikee marked this conversation as resolved.
Resolved
Comment thread
thymikee marked this conversation as resolved.
Resolved
* The client and run a session allocates under on a leased remote-config profile.
*
* Every session that names one profile would otherwise carry the profile's client and run, and the
* daemon reuses a lease for a matching run binding, so one session could take over, and release,
* another's lease (#3931). Each session gets its own client and run, derived from its name, so the
* same session finds its own lease again. A profile that names a lease the host issued keeps that
* lease's scope.
*/
export function sessionLeaseScope(
profile: Pick<RemoteConnectionState, 'leaseProvider' | 'leaseId' | 'clientId'> & {
runId: string | undefined;
},
session: string,
): { runId: string | undefined; clientId: string | undefined } {
if (!isLeasedConnection(profile) || profile.leaseId !== undefined) {
return { runId: profile.runId, clientId: profile.clientId };
}
const suffix = crypto.createHash('sha256').update(session).digest('hex').slice(0, 16);
return {
runId:
profile.runId && `${profile.runId.slice(0, MAX_RUN_ID_LENGTH - suffix.length - 1)}.${suffix}`,
clientId:
profile.clientId === undefined ? suffix : `${profile.clientId.slice(0, 111)}.${suffix}`,
};

Check notice on line 398 in packages/remote/src/remote-connection-state.ts

View check run for this annotation

apex-sentinel-staging / Apex Sentinel

nit: sessionLeaseScope truncates clientId by the run-id limit, not the client-id limit

`sessionLeaseScope` declares one constant, `MAX_RUN_ID_LENGTH = 128`, and uses it to size the run id (`MAX_RUN_ID_LENGTH - suffix.length - 1`), but hardcodes `111` for the client id — the same number reached by the run-id arithmetic, not by the client-id limit the daemon actually checks. The daemon validates the two fields separately: `normalizeClientId` calls `normalizeAgentIdentifier(raw, 'client id', 128)` while `normalizeRunId` applies `/^[a-zA-Z0-9._-]{1,128}$/` (packages/daemon/src/resources/lease-registry-scope.ts:163-165, 124-130). The truncation is correct today only because those two limits happen to be equal. If the client-id limit is ever lowered (or the run-id constant edited), every `open --remote-config` on a leased profile whose `clientId` is longer than that limit is refused by the daemon with `INVALID_ARGS: Invalid client id. Use 1-N chars...` on the allocation, and the failure points at the daemon rather than at this derivation. Two named constants (or deriving both from one shared lease-scope limit) make the invariant explicit. ### Also raised here Also raised at this location by senior-developer: sessionLeaseScope clientId truncation hard-codes 111, duplicating the run-id length math it silently depends on — `sessionLeaseScope` derives a per-session suffix of 16 hex chars and appends `.${suffix}` to both the run id and the client id. The run id is truncated with `MAX_RUN_ID_LENGTH - suffix.length - 1` (= 111 today), but the client id is truncated with the magic literal `111` (packages/remote/src/remote-connection-state.ts:397). The daemon validates both fields against the same 128-char ceiling with the same charset — `normalizeClientId(raw, 'client id', 128)` and `normalizeRunId` in packages/daemon/src/resources/lease-registry-scope.ts:164/203 — so 111 is only correct because `MAX_RUN_ID_LENGTH` is 128 and the suffix is 16 hex chars. If either constant changes (a longer suffix, a different cap, or a separate client-id cap), the client id sile…
}

/**
* Refuses a caller that named no session the use of a leased connection: one whose device a lease
* provider (proxy, a device cloud, or a provider plugin) leases for a single agent.
*
* On a machine several agents share, an unnamed session resolves to whichever connection was saved
* last, which may be another agent's.
*/
export function requireLeasedConnectionSession(
Comment thread
thymikee marked this conversation as resolved.
Resolved
connection: Pick<RemoteConnectionState, 'leaseProvider'>,
session: string | undefined,
): void {
if (session !== undefined || !isLeasedConnection(connection)) return;
throw remoteSessionRequiredError(connection.leaseProvider);
}

/** The refusal for a command on a leased connection that names no session. */
export function remoteSessionRequiredError(leaseProvider: string | undefined): AppError {
Comment thread
thymikee marked this conversation as resolved.
Resolved
return new AppError(
'INVALID_ARGS',
'A leased remote connection needs its session named: pass --session <name> or set AGENT_DEVICE_SESSION.',
{
reason: 'REMOTE_SESSION_REQUIRED',
...(leaseProvider === undefined ? {} : { leaseProvider }),
hint: "Without a name, a command could reach another agent's connection on this machine.",
},
);
}

function isLeasedConnection(connection: Pick<RemoteConnectionState, 'leaseProvider'>): boolean {
Comment thread
thymikee marked this conversation as resolved.
Resolved
return connection.leaseProvider !== undefined;
}

export function buildRemoteConnectionRequestMetadata(
state: RemoteConnectionRequestMetadata,
): RemoteConnectionRequestMetadata | undefined {
Expand Down
15 changes: 9 additions & 6 deletions src/__tests__/cli-config-limrun.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,14 @@ test('Limrun apps lists uploaded assets without allocating an instance', async (
},
});

const result = await runCliCapture(['apps', '--state-dir', stateDir, '--json'], {
cwd: project,
env: { HOME: home },
defaultResponse: { ok: true, data: { apps: ['Example.apk'] } },
});
const result = await runCliCapture(
['apps', '--session', 'limrun-apps', '--state-dir', stateDir, '--json'],
{
cwd: project,
env: { HOME: home },
defaultResponse: { ok: true, data: { apps: ['Example.apk'] } },
},
);

assert.equal(result.code, null);
assert.equal(result.calls.length, 1);
Expand Down Expand Up @@ -125,7 +128,7 @@ test('Limrun open allocates with the exact uploaded asset name', async () => {
});

const result = await runCliCapture(
['open', 'Example.app.zip', '--state-dir', stateDir, '--json'],
['open', 'Example.app.zip', '--session', 'limrun-open', '--state-dir', stateDir, '--json'],
{
cwd: project,
env: { HOME: home },
Expand Down
Loading
Loading