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
21 changes: 7 additions & 14 deletions docs/agents/code-ownership.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
# Code ownership

Read this for shared contracts, package boundaries, request routing, or diagnostics.
Before you edit a module with many callers, run `pnpm depgraph affected <path>` to identify callers and guarantees.

## Declarations

Read the owner. Do not copy its policy into consumers.

| Question | Owner |
| --- | --- |
| Command names | `packages/command-registry/src/catalog.ts` |
Expand All @@ -15,8 +12,11 @@ Read the owner. Do not copy its policy into consumers.
| Interaction guarantees | `packages/contracts/src/interaction-guarantees.ts` |
| Runtime-fact admission | `packages/daemon/src/core/runtime-admission.ts`, `src/platform-runtime-gateway.ts` |
| Input fields and audiences | `src/commands/common-input-fields.ts`, `src/commands/input-audience.ts` |
| Released SDK default hosts | `src/sdk/host.ts` |

Shared selectors belong in `@agent-device/selectors`.
For SDK entries `android-adb`, `artifacts`, `limrun`, `metro`, and `remote-config`, use `captureSdkHost` for defaults.
For command-host readers, use `withSdkHost`.
Keep provider callbacks on their supplied host.
Request cancellation and progress belong in `@agent-device/host-kit/request`.
Per-call abort guards belong beside transports in `src/daemon-client/daemon-client-transport.ts`.
Contracts shared across layers belong in `packages/contracts/src`.
Expand All @@ -32,16 +32,9 @@ Before you add behavior, split files over 1,000 lines, except generated or fixtu
Split a source module and its tests together.
Do not extend legacy interaction or platform index test aggregations.
Keep tests beside their named module. Put repository artifact guards in `test/repo-guards/`.
Shared helpers belong in `@agent-device/test-kit`, a devDependency.
Production code never imports test-kit.
Test-kit never depends on:

- Platforms.
- Providers.
- The daemon.
- Root.

Do not export test support from production packages.
Shared test support belongs in `@agent-device/test-kit`, a devDependency.
Production code must not import test-kit.
Test-kit must not depend on platforms, providers, the daemon, or root.

## Diagnostics

Expand Down
16 changes: 14 additions & 2 deletions scripts/layering/process-globals-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ test('entry declarations permit process reads but do not permit ambient command-
'packages/capture-kit/src/png-worker.ts',
'packages/maestro/src/daemon-port/run-script-http-child.ts',
'packages/react-native/src/companion/client-companion-tunnel-worker.ts',
'src/sdk/android-adb.ts',
'src/sdk/host.ts',
'src/io.ts',
]) {
assert.deepEqual(check('process.env; process.on("SIGINT", stop);', file), [], file);
Expand Down Expand Up @@ -120,7 +120,19 @@ test('bare process values are captures in calls, aliases, spreads, returns and c
"import * as p from 'node:process'; captureHostEnvironment(p.default);",
]) {
assert.equal(check(source).length, 1, source);
assert.deepEqual(check(source, 'src/sdk/android-adb.ts'), [], source);
assert.deepEqual(check(source, 'src/sdk/host.ts'), [], source);
}
});

test('SDK adapters capture their default host only through the shared entry', () => {
for (const file of [
'src/sdk/android-adb.ts',
'src/sdk/artifacts.ts',
'src/sdk/metro.ts',
'src/sdk/remote-config.ts',
'src/provider-limrun-runtime.ts',
]) {
assert.equal(check('captureHostEnvironment(process);', file).length, 1, file);
}
});

Expand Down
5 changes: 1 addition & 4 deletions scripts/layering/process-globals-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,7 @@ const PROCESS_ENTRIES = new Set([
'packages/maestro/src/daemon-port/run-script-http-child.ts',
// Released SDK defaults; #3759 removes these captures after agent-device/host ships (#3757).
'src/io.ts',
'src/sdk/remote-config.ts',
'src/sdk/android-adb.ts',
'src/sdk/metro.ts',
'src/provider-limrun-runtime.ts',
'src/sdk/host.ts',
'src/mcp/command-tools.ts',
'src/daemon-client/daemon-client.ts',
'src/daemon-client/daemon-client-ensure.ts',
Expand Down
4 changes: 2 additions & 2 deletions src/provider-limrun-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { captureHostEnvironment } from '@agent-device/host-kit/process';
import { captureSdkHost } from './sdk/host.ts';
import type {
DeviceInventoryProvider,
LeaseLifecycleProvider,
Expand Down Expand Up @@ -27,7 +27,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime {
constructor(options: LimrunRuntimeOptions) {
this.implementation = createLimrunRuntime(
options,
createLimrunRuntimeDependencies(captureHostEnvironment(process)),
createLimrunRuntimeDependencies(captureSdkHost()),
);
}

Expand Down
13 changes: 3 additions & 10 deletions src/sdk/android-adb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,14 @@ import {
runAndroidExecOut as runAndroidExecOutOnHost,
runAndroidShell as runAndroidShellOnHost,
} from '@agent-device/platform-android/mechanics';
import { captureHostEnvironment } from '@agent-device/host-kit/process';
import { withCommandHost } from '@agent-device/host-kit/command';
import { withSdkHost } from './host.ts';

export async function runAndroidShell(...args: Parameters<typeof runAndroidShellOnHost>) {
const host = captureHostEnvironment(process);
return await withCommandHost({ host, requestEnv: undefined }, () =>
runAndroidShellOnHost(...args),
);
return await withSdkHost(() => runAndroidShellOnHost(...args));
}

export async function runAndroidExecOut(...args: Parameters<typeof runAndroidExecOutOnHost>) {
const host = captureHostEnvironment(process);
return await withCommandHost({ host, requestEnv: undefined }, () =>
runAndroidExecOutOnHost(...args),
);
return await withSdkHost(() => runAndroidExecOutOnHost(...args));
}
export type { ShellWord } from '@agent-device/kernel/device-shell';

Expand Down
60 changes: 60 additions & 0 deletions src/sdk/artifacts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { test } from 'vitest';
import { mkdtempForTestSync } from '@agent-device/test-kit';
import { withCommandHost } from '@agent-device/host-kit/command';
import { resolveAndroidArchivePackageName } from './artifacts.ts';

test.skipIf(process.platform === 'win32')(
'standalone artifact resolution uses aapt after an unparseable manifest without poisoning later scoped calls',
async () => {
const root = mkdtempForTestSync('agent-device-sdk-artifacts-');
const sdk = path.join(root, 'sdk');
const tools = path.join(sdk, 'build-tools', '99.0.0');
mkdirSync(tools, { recursive: true });
const manifest = path.join(root, 'AndroidManifest.xml');
writeFileSync(manifest, '<manifest><application /></manifest>');
const archive = path.join(root, 'fallback.apk');
execFileSync('zip', ['-q', archive, 'AndroidManifest.xml'], { cwd: root });
const calls = path.join(root, 'aapt-calls.jsonl');
const aapt = path.join(tools, 'aapt');
writeFileSync(
aapt,
`#!${process.execPath}\n` +
`require('node:fs').appendFileSync(${JSON.stringify(calls)}, JSON.stringify(process.argv.slice(2))+'\\n');\n` +
`process.stdout.write("package: name='com.example.fallback' versionCode='1'\\n");\n`,
);
chmodSync(aapt, 0o755);
const previous = {
ANDROID_HOME: process.env.ANDROID_HOME,
ANDROID_SDK_ROOT: process.env.ANDROID_SDK_ROOT,
};
process.env.ANDROID_HOME = sdk;
process.env.ANDROID_SDK_ROOT = sdk;
try {
const standalone = await resolveAndroidArchivePackageName(archive);
const scoped = await withCommandHost(
{ host: { env: { ...process.env }, cwd: root }, requestEnv: undefined },
() => resolveAndroidArchivePackageName(archive),
);
assert.deepEqual([standalone, scoped], ['com.example.fallback', 'com.example.fallback']);
assert.deepEqual(
readFileSync(calls, 'utf8')
.trim()
.split('\n')
.map((line) => JSON.parse(line)),
[
['dump', 'badging', archive],
['dump', 'badging', archive],
],
);
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
},
);
7 changes: 5 additions & 2 deletions src/sdk/artifacts.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { loadAndroidMechanics } from '../platform-runtime-android-mechanics.ts';
import { withSdkHost } from './host.ts';

type ResolveAndroidArchivePackageName =
typeof import('@agent-device/platform-android/mechanics').resolveAndroidArchivePackageName;

export async function resolveAndroidArchivePackageName(
...args: Parameters<ResolveAndroidArchivePackageName>
): Promise<Awaited<ReturnType<ResolveAndroidArchivePackageName>>> {
const { resolveAndroidArchivePackageName: resolve } = await loadAndroidMechanics();
return await resolve(...args);
return await withSdkHost(async () => {
const { resolveAndroidArchivePackageName: resolve } = await loadAndroidMechanics();
return await resolve(...args);
});
}
12 changes: 12 additions & 0 deletions src/sdk/host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { HostEnvironment } from '@agent-device/kernel/source-value';
import { captureHostEnvironment } from '@agent-device/host-kit/process';
import { withCommandHost } from '@agent-device/host-kit/command';

/** Released SDK defaults until the explicit host entry ships (#3759). */
export function captureSdkHost(): HostEnvironment {
return captureHostEnvironment(process);
}

export async function withSdkHost<T>(task: () => Promise<T>): Promise<T> {
return await withCommandHost({ host: captureSdkHost(), requestEnv: undefined }, task);
}
87 changes: 87 additions & 0 deletions src/sdk/limrun-host-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { test } from 'vitest';
import { mkdtempForTestSync } from '@agent-device/test-kit';
import { withCommandHost } from '@agent-device/host-kit/command';
import { createLimrunRuntimeDependencies } from './limrun-runtime-dependencies.ts';

test.skipIf(process.platform === 'win32')(
'Limrun archive callbacks retain their owning host outside and across concurrent foreign scopes',
async () => {
const root = mkdtempForTestSync('agent-device-limrun-host-');
const bin = path.join(root, 'bin');
mkdirSync(bin);
const zip = path.join(bin, 'zip');
writeFileSync(
zip,
`#!${process.execPath}\n` +
`require('node:fs').writeFileSync(process.argv[3], JSON.stringify({args:process.argv.slice(2),host:process.env.LIMRUN_OWNER}));\n`,
);
chmodSync(zip, 0o755);
const owner = (name: string) =>
createLimrunRuntimeDependencies({
env: { ...process.env, PATH: bin, LIMRUN_OWNER: name },
cwd: root,
});
const first = owner('first');
const second = owner('second');
const archive = (name: string) => ({
sourceDirectory: root,
entryName: 'Example.app',
archivePath: path.join(root, `${name}.zip`),
});
const previous = { PATH: process.env.PATH, LIMRUN_OWNER: process.env.LIMRUN_OWNER };
process.env.PATH = bin;
process.env.LIMRUN_OWNER = 'ambient';
try {
const standalone = archive('standalone');
await first.host.archiveDirectory(standalone);
const left = archive('left'),
right = archive('right');
await withCommandHost(
{
host: { env: { ...process.env, PATH: bin, LIMRUN_OWNER: 'foreign' }, cwd: root },
requestEnv: { LIMRUN_OWNER: 'foreign-request' },
},
() => Promise.all([first.host.archiveDirectory(left), second.host.archiveDirectory(right)]),
);
for (const [request, host] of [
[standalone, 'first'],
[left, 'first'],
[right, 'second'],
] as const) {
assert.deepEqual(JSON.parse(readFileSync(request.archivePath, 'utf8')), {
args: ['-qr', request.archivePath, 'Example.app'],
host,
});
}
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
},
);

test.skipIf(process.platform === 'win32')(
'Limrun bundle inference resolves plist tooling from its supplied host without an outer scope',
async () => {
const root = mkdtempForTestSync('agent-device-limrun-plist-');
const plutil = path.join(root, 'plutil');
writeFileSync(
plutil,
`#!${process.execPath}\nprocess.stdout.write(JSON.stringify({CFBundleDisplayName:'Scoped App'}));\n`,
);
chmodSync(plutil, 0o755);
const dependencies = createLimrunRuntimeDependencies({
env: { ...process.env, PATH: root },
cwd: root,
});
assert.equal(
await dependencies.ios.readBundleAppName(path.join(root, 'Example.app')),
'Scoped App',
);
},
);
Loading
Loading