Skip to content
Draft
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
3 changes: 2 additions & 1 deletion .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@
},
{
"comment": "Dedicated CLI command handlers are reached only through the dynamic `import()` table `dedicatedCliCommandHandlerLoaders` in src/cli/commands/router.ts, which --production analysis cannot follow to a consumer. Same shape as the daemon route-handler entry above; that table is what enumerates this list, so add/remove here whenever a loader is added/removed.",
"file": "src/cli/commands/{auth,connection,daemon,device,plugins,proxy,recording,replay,screenshot,takeover}.ts",
"file": "src/cli/commands/{auth,connection,daemon,device,host,plugins,proxy,recording,replay,screenshot,takeover}.ts",
"exports": [
"authCommand",
"pluginsCommand",
Expand All @@ -229,6 +229,7 @@
"connectionCommand",
"daemonCommand",
"deviceCommand",
"hostCommand",
"proxyCommand",
"recordingCommand",
"replayCommand",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ test('every command that deviates from require-owner is a reviewed, diffable set
'daemon',
'debug',
'disconnect',
'host',
'human_control',
'install-from-source',
'lease_allocate',
Expand Down
22 changes: 20 additions & 2 deletions packages/command-registry/src/flag-definitions-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export const CONNECTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
names: ['--host'],
type: 'string',
usageLabel: '--host <host>',
usageDescription: 'Proxy: host interface to bind (default: 127.0.0.1)',
usageDescription: 'Proxy and host: interface to bind (default: 127.0.0.1)',
projectConfig: false,
recorded: false,
},
Expand All @@ -87,7 +87,25 @@ export const CONNECTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
min: 1,
max: 65535,
usageLabel: '--port <port>',
usageDescription: 'Proxy: TCP port to bind (default: 0, choose a free port)',
usageDescription: 'Proxy and host: TCP port to bind (default: 0, choose a free port)',
projectConfig: false,
recorded: false,
},
{
key: 'hostTlsCert',
names: ['--tls-cert'],
type: 'string',
usageLabel: '--tls-cert <path>',
usageDescription: 'Host: PEM certificate to serve HTTPS (requires --tls-key)',
projectConfig: false,
recorded: false,
},
{
key: 'hostTlsKey',
names: ['--tls-key'],
type: 'string',
usageLabel: '--tls-key <path>',
usageDescription: 'Host: PEM private key to serve HTTPS (requires --tls-cert)',
projectConfig: false,
recorded: false,
},
Expand Down
3 changes: 2 additions & 1 deletion packages/command-registry/src/flag-definitions-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,8 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
names: ['--github-actions-artifact'],
type: 'string',
usageLabel: '--github-actions-artifact <owner/repo:artifact>',
usageDescription: 'install-from-source: GitHub Actions artifact resolved by a remote daemon',
usageDescription:
'install-from-source: GitHub Actions artifact the daemon resolves with its own token',
projectConfig: false,
recorded: false,
},
Expand Down
11 changes: 11 additions & 0 deletions packages/command-registry/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1760,6 +1760,17 @@ export const RAW_COMMAND_DESCRIPTORS = [
mcpExposed: false,
platformExecution: NO_PLATFORM_EXECUTION,
},
{
name: 'host',
deviceClaimPolicy: 'none',
...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/host.ts'] as const } : {}),
catalog: { group: 'local-cli' },
recordsSessionAction: false,
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
mcpExposed: false,
platformExecution: NO_PLATFORM_EXECUTION,
},
{
name: 'proxy',
deviceClaimPolicy: 'none',
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export type CliFlags = CloudProviderProfileFields &
daemonServerMode?: DaemonServerMode;
proxyHost?: string;
proxyPort?: number;
hostTlsCert?: string;
hostTlsKey?: string;
tenant?: string;
sessionIsolation?: SessionIsolationMode;
runId?: string;
Expand Down
20 changes: 19 additions & 1 deletion packages/contracts/src/daemon-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ export const DAEMON_HTTP_BASE_PATH = '/agent-device';
export const DAEMON_HTTP_TENANT_HEADER = 'x-agent-device-tenant';
export const DAEMON_HTTP_NETWORK_ACCESS_HEADER = 'x-agent-device-network-access';
export const DAEMON_HTTP_PUBLIC_NETWORK_ACCESS = 'public-only';
/**
* The principal the Host front-end authenticated (ADR 0021 §6). The daemon trusts it only on a
* request that already carries the daemon token, and the proxy never forwards it from a client.
*/
export const DAEMON_HTTP_PRINCIPAL_HEADER = 'x-agent-device-principal';

export function buildDaemonHttpBaseUrl(baseUrl: string): string {
return buildDaemonHttpUrl(baseUrl, DAEMON_HTTP_BASE_PATH);
Expand Down Expand Up @@ -52,16 +57,27 @@ export function buildDaemonInstanceMismatchRpcResponse<Id>(

export type DaemonHealthPayload = {
ok: true;
service: 'agent-device-daemon' | 'agent-device-proxy';
service: 'agent-device-daemon' | 'agent-device-proxy' | typeof DAEMON_HOST_SERVICE;
version: string;
rpcProtocolVersion: number;
instanceId?: string;
hostArch?: string;
/** The lease backends this daemon admits; a host checks it before relying on one. */
leaseBackends?: readonly string[];
/** Optional capabilities a client checks before sending a request that relies on one. */
features?: readonly DaemonHealthFeature[];
upstream?: unknown;
};

/**
* Host allocates a fresh device per lease from a shape (`--device "iPhone 16"`) instead of a
* local inventory identity (ADR 0021 §5). A client sends a shape only to a peer advertising it.
*/
export const DAEMON_HOST_DEVICE_SHAPE_FEATURE = 'device-shape';
/** The `service` a Host front-end reports, which a client reads to treat `--device` as a type. */
export const DAEMON_HOST_SERVICE = 'agent-device-host';
export type DaemonHealthFeature = typeof DAEMON_HOST_DEVICE_SHAPE_FEATURE;

export function buildDaemonHealthPayload(
service: DaemonHealthPayload['service'],
version: string,
Expand All @@ -70,6 +86,7 @@ export function buildDaemonHealthPayload(
instanceId?: string;
hostArch?: string;
leaseBackends?: readonly string[];
features?: readonly DaemonHealthFeature[];
} = {},
): DaemonHealthPayload {
return {
Expand All @@ -80,6 +97,7 @@ export function buildDaemonHealthPayload(
...(options.instanceId !== undefined ? { instanceId: options.instanceId } : {}),
...(options.hostArch !== undefined ? { hostArch: options.hostArch } : {}),
...(options.leaseBackends !== undefined ? { leaseBackends: options.leaseBackends } : {}),
...(options.features !== undefined ? { features: options.features } : {}),
...(options.upstream !== undefined ? { upstream: options.upstream } : {}),
};
}
4 changes: 4 additions & 0 deletions packages/provision-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
"types": "./src/install-source-network.ts",
"default": "./src/install-source-network.ts"
},
"./github-actions-artifact-source": {
"types": "./src/github-actions-artifact-source.ts",
"default": "./src/github-actions-artifact-source.ts"
},
"./install-source-network-transport": {
"types": "./src/install-source-network-transport.ts",
"default": "./src/install-source-network-transport.ts"
Expand Down
157 changes: 157 additions & 0 deletions packages/provision-kit/src/github-actions-artifact-source.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import assert from 'node:assert/strict';
import dns from 'node:dns/promises';
import fs from 'node:fs/promises';
import { Readable } from 'node:stream';
import { afterEach, test, vi } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import {
resolveGitHubActionsArtifactSource,
type GitHubArtifactResolutionOptions,
} from './github-actions-artifact-source.ts';
import { downloadInstallSource } from './install-source-download.ts';
import * as networkTransport from './install-source-network-transport.ts';
import { mkdtempForTest } from './tmp-dir.fixtures.ts';

const ARCHIVE_URL = 'https://api.github.com/repos/acme/mobile/actions/artifacts/42/zip';
const OWN_RUN = { repository_id: 1, head_repository_id: 1 };
const FORK_RUN = { repository_id: 1, head_repository_id: 2 };

function response(
statusCode: number,
body: unknown,
headers: Record<string, string> = {},
): networkTransport.InstallSourceNetworkResponse {
const bytes = Buffer.isBuffer(body) ? body : Buffer.from(JSON.stringify(body));
return { statusCode, headers, body: Readable.from([bytes]), close: async () => {} };
}

function network(...responses: networkTransport.InstallSourceNetworkResponse[]) {
vi.spyOn(dns, 'lookup').mockImplementation(
async () =>
[{ address: '140.82.112.6', family: 4 }] as unknown as Awaited<ReturnType<typeof dns.lookup>>,
);
const request = vi.spyOn(networkTransport, 'requestApprovedUrl');
for (const next of responses) request.mockResolvedValueOnce(next);
return request;
}

function options(overrides: Partial<GitHubArtifactResolutionOptions> = {}) {
return {
token: 'ghs_daemon',
tokenSource: 'AGENT_DEVICE_GITHUB_TOKEN',
signal: new AbortController().signal,
...overrides,
};
}

async function reasonOf(run: Promise<unknown>): Promise<unknown> {
try {
await run;
} catch (error) {
assert.ok(error instanceof AppError, String(error));
return error.details?.reason ?? error.message;
}
assert.fail('expected a refusal');
}

const byName = (artifactName: string) =>
({ kind: 'github-actions-artifact', owner: 'acme', repo: 'mobile', artifactName }) as const;
const byId = {
kind: 'github-actions-artifact',
owner: 'acme',
repo: 'mobile',
artifactId: 42,
} as const;

afterEach(() => {
vi.restoreAllMocks();
});

test('a name picks the newest live build of the repository itself, never a fork pull request', async () => {
const request = network(
response(200, {
artifacts: [
{ archive_download_url: ARCHIVE_URL.replace('42', '99'), workflow_run: FORK_RUN },
{
archive_download_url: ARCHIVE_URL.replace('42', '43'),
expired: true,
workflow_run: OWN_RUN,
},
{ archive_download_url: ARCHIVE_URL, workflow_run: OWN_RUN },
],
}),
);

const resolved = await resolveGitHubActionsArtifactSource(byName('ios sim'), options());

const call = request.mock.calls[0]![0];
assert.equal(
call.url.toString(),
'https://api.github.com/repos/acme/mobile/actions/artifacts?name=ios%20sim&per_page=100',
);
assert.equal(call.headers.authorization, 'Bearer ghs_daemon');
assert.equal(resolved.url, ARCHIVE_URL);
});

test('refusals are typed, and the ones that need no request reach no network', async () => {
const request = network();
const local: Array<[unknown, Promise<unknown>]> = [
[
'github-token-missing',
resolveGitHubActionsArtifactSource(byId, options({ token: undefined })),
],
[
'github-repository-not-allowed',
resolveGitHubActionsArtifactSource(byId, options({ allowedRepositories: ['acme/other'] })),
],
[
'Invalid GitHub repository name: ..',
resolveGitHubActionsArtifactSource({ ...byId, owner: '..' }, options()),
],
];
for (const [reason, run] of local) assert.equal(await reasonOf(run), reason);
assert.equal(request.mock.calls.length, 0);

network(
response(404, { message: 'Not Found' }),
response(200, { archive_download_url: ARCHIVE_URL, expired: true }),
response(200, { archive_download_url: 'https://evil.example/zip' }),
response(401, { message: 'Bad credentials' }),
response(200, Buffer.from('<html>portal</html>')),
);
for (const reason of [
'github-artifact-not-found',
'github-artifact-expired',
'github-artifact-url-unexpected',
'github-token-rejected',
'github-api-invalid-response',
]) {
assert.equal(await reasonOf(resolveGitHubActionsArtifactSource(byId, options())), reason);
}
});

test('the archive download drops the daemon token at the storage redirect', async () => {
const tempDir = await mkdtempForTest('agent-device-github-artifact-');
try {
const request = network(
response(200, { artifacts: [{ archive_download_url: ARCHIVE_URL }] }),
response(302, Buffer.alloc(0), {
location: 'https://productionresults.blob.core.windows.net/artifact.zip?sig=x',
}),
response(200, Buffer.from('zip')),
);

const resolved = await resolveGitHubActionsArtifactSource(
{ ...byName('ios'), runId: 7 },
options(),
);
const archive = await downloadInstallSource({ tempDir, ...resolved, signal: options().signal });

assert.equal(await fs.readFile(archive, 'utf8'), 'zip');
assert.match(request.mock.calls[0]![0].url.pathname, /\/runs\/7\/artifacts$/);
assert.equal(request.mock.calls[1]![0].headers.authorization, 'Bearer ghs_daemon');
assert.equal(request.mock.calls[2]![0].headers.authorization, undefined);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
Loading