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
13 changes: 12 additions & 1 deletion .github/workflows/publish-mcp-registry.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ jobs:
name: Publish MCP Registry
runs-on: ubuntu-latest
timeout-minutes: 10
env:
MCP_PUBLISHER_VERSION: v1.8.1
MCP_PUBLISHER_ASSET: mcp-publisher_linux_amd64.tar.gz
MCP_PUBLISHER_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down Expand Up @@ -64,7 +68,14 @@ jobs:
- name: Install mcp-publisher
run: |
set -euo pipefail
curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
PUBLISHER_ARCHIVE="$RUNNER_TEMP/$MCP_PUBLISHER_ASSET"
curl --fail --show-error --location --retry 3 \
--output "$PUBLISHER_ARCHIVE" \
"https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/${MCP_PUBLISHER_ASSET}"
printf '%s %s\n' "$MCP_PUBLISHER_SHA256" "$PUBLISHER_ARCHIVE" | sha256sum --check --strict
tar -xzf "$PUBLISHER_ARCHIVE" -C "$RUNNER_TEMP" mcp-publisher
install -m 0755 "$RUNNER_TEMP/mcp-publisher" ./mcp-publisher
./mcp-publisher --version
shell: bash

- name: Authenticate to MCP Registry
Expand Down
10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -245,25 +245,31 @@
],
"dependencies": {
"@limrun/api": "^0.24.5",
"yaml": "^2.9.0"
"ipaddr.js": "^2.5.0",
"tar-stream": "^3.2.0",
"undici": "7.29.0",
"yaml": "^2.9.0",
"yauzl": "^3.4.0"
},
"devDependencies": {
"@agent-device/ad-replay": "workspace:*",
"@agent-device/ad-script": "workspace:*",
"@agent-device/contracts": "workspace:*",
"@agent-device/kernel": "workspace:*",
"@agent-device/selectors": "workspace:*",
"@agent-device/maestro": "workspace:*",
"@agent-device/provider-limrun": "workspace:*",
"@agent-device/provider-webdriver": "workspace:*",
"@agent-device/replay-test": "workspace:*",
"@agent-device/selectors": "workspace:*",
"@agent-device/xml": "workspace:*",
"@arethetypeswrong/cli": "^0.18.5",
"@chenglou/freerange": "^0.0.1",
"@stryker-mutator/core": "9.6.1",
"@stryker-mutator/vitest-runner": "9.6.1",
"@types/node": "^22.19.21",
"@types/pngjs": "^6.0.5",
"@types/tar-stream": "^3.1.4",
"@types/yauzl": "^2.10.3",
"@vitest/coverage-v8": "4.1.8",
"fallow": "^2.95.0",
"fast-check": "^4.9.0",
Expand Down
398 changes: 292 additions & 106 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions src/__tests__/npm-package-scripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ const packagedCliWorkflow = fs.readFileSync(
path.join(repoRoot, '.github', 'workflows', 'ci.yml'),
'utf8',
);
const mcpRegistryWorkflow = fs.readFileSync(
path.join(repoRoot, '.github', 'workflows', 'publish-mcp-registry.yml'),
'utf8',
);

function script(name: string): string {
const value = packageJson.scripts[name];
Expand Down Expand Up @@ -110,3 +114,39 @@ test('publishing cannot skip the package gate', () => {
/run: node --experimental-strip-types scripts\/check-package\.ts/,
);
});

test('MCP registry publishing verifies an immutable publisher before OIDC login', () => {
assert.match(mcpRegistryWorkflow, /MCP_PUBLISHER_VERSION: v1\.8\.1/);
assert.match(mcpRegistryWorkflow, /MCP_PUBLISHER_ASSET: mcp-publisher_linux_amd64\.tar\.gz/);
assert.match(
mcpRegistryWorkflow,
/MCP_PUBLISHER_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc/,
);
assert.doesNotMatch(mcpRegistryWorkflow, /releases\/latest/);
assert.doesNotMatch(mcpRegistryWorkflow, /curl[^\n]*\|[^\n]*tar/);

const downloadIndex = mcpRegistryWorkflow.indexOf(
'/releases/download/${MCP_PUBLISHER_VERSION}/${MCP_PUBLISHER_ASSET}',
);
const verificationIndex = mcpRegistryWorkflow.indexOf('sha256sum --check --strict');
const extractionIndex = mcpRegistryWorkflow.indexOf('tar -xzf');
const smokeCheckIndex = mcpRegistryWorkflow.indexOf('./mcp-publisher --version');
const loginIndex = mcpRegistryWorkflow.indexOf('./mcp-publisher login github-oidc');
const publishIndex = mcpRegistryWorkflow.indexOf('./mcp-publisher publish server.json');

for (const [label, index] of [
['versioned download', downloadIndex],
['checksum verification', verificationIndex],
['archive extraction', extractionIndex],
['version smoke check', smokeCheckIndex],
['OIDC login', loginIndex],
['registry publish', publishIndex],
] as const) {
assert.notEqual(index, -1, `workflow must contain ${label}`);
}
assert.ok(downloadIndex < verificationIndex, 'download must precede checksum verification');
assert.ok(verificationIndex < extractionIndex, 'verification must precede extraction');
assert.ok(extractionIndex < smokeCheckIndex, 'installation must precede the version smoke check');
assert.ok(smokeCheckIndex < loginIndex, 'version smoke check must precede OIDC login');
assert.ok(loginIndex < publishIndex, 'OIDC login must precede publish');
});
24 changes: 24 additions & 0 deletions src/daemon/__tests__/resumable-upload-range.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { parseUploadContentLength, parseUploadContentRange } from '../resumable-upload-range.ts';

test('content ranges are bounded by the declared upload size', () => {
assert.deepEqual(parseUploadContentRange('bytes 2-4/5', 5), {
start: 2,
end: 4,
size: 5,
span: 3,
});
for (const value of ['bytes 0-5/5', 'bytes 5-5/5', 'bytes 0-0/0']) {
assert.throws(() => parseUploadContentRange(value, Number(value.split('/')[1])));
}
});

test('content range and length numbers use decimal safe-integer grammar', () => {
for (const value of ['+1', '1e3', '0x10', '1.5', '-1', '9007199254740992']) {
assert.throws(() => parseUploadContentLength(value), value);
}
assert.equal(parseUploadContentLength('0'), 0);
assert.equal(parseUploadContentLength('123'), 123);
assert.equal(parseUploadContentLength(undefined), undefined);
});
154 changes: 152 additions & 2 deletions src/daemon/__tests__/resumable-upload.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { test } from 'vitest';
import { test, vi } from 'vitest';
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import fs from 'node:fs';
import { PassThrough, Readable } from 'node:stream';
import type { IncomingMessage } from 'node:http';
import { AppError } from '@agent-device/kernel/errors';
import { finalizeResumableUpload } from '../resumable-upload.ts';
import {
beginResumableUpload,
finalizeResumableUpload,
receiveResumableUploadChunk,
} from '../resumable-upload.ts';

test('finalizing an unknown upload reports expiry with a recovery hint', async () => {
const error = await finalizeResumableUpload('missing-upload-id').then(
Expand All @@ -15,3 +23,145 @@ test('finalizing an unknown upload reports expiry with a recovery hint', async (
assert.equal(appError.details?.reason, 'RESOURCE_EXPIRED');
assert.equal(typeof appError.details?.hint, 'string');
});

test('oversized ranged chunks roll back atomically and can be retried and finalized', async () => {
const bytes = Buffer.from('ABCDE');
const uploadId = beginUpload(bytes).uploadId;
await assert.rejects(
receiveResumableUploadChunk({
uploadId,
req: request(Buffer.from('ABC'), { 'content-range': 'bytes 0-1/5' }),
}),
/permitted byte range/i,
);
assert.deepEqual(
await receiveResumableUploadChunk({
uploadId,
req: request(Buffer.from('AB'), { 'content-range': 'bytes 0-1/5' }),
}),
{ complete: false, offset: 2 },
);
await receiveResumableUploadChunk({
uploadId,
req: request(Buffer.from('CDE'), { 'content-range': 'bytes 2-4/5' }),
});
const finalized = await finalizeResumableUpload(uploadId);
try {
assert.equal(fs.readFileSync(finalized.artifactPath, 'utf8'), 'ABCDE');
} finally {
fs.rmSync(finalized.tempDir, { recursive: true, force: true });
}
});

test('an early finalize keeps the upload resumable', async () => {
const bytes = Buffer.from('resume');
const uploadId = beginUpload(bytes).uploadId;
await assert.rejects(finalizeResumableUpload(uploadId), /incomplete/i);
await receiveResumableUploadChunk({ uploadId, req: request(bytes) });
const finalized = await finalizeResumableUpload(uploadId);
fs.rmSync(finalized.tempDir, { recursive: true, force: true });
});

test('per-ticket operations serialize while an earlier chunk is paused', async () => {
const bytes = Buffer.from('ABCD');
const uploadId = beginUpload(bytes).uploadId;
const first = requestStream({ 'content-range': 'bytes 0-1/4' });
const second = requestStream({ 'content-range': 'bytes 2-3/4' });
const firstResult = receiveResumableUploadChunk({ uploadId, req: first });
const secondResult = receiveResumableUploadChunk({ uploadId, req: second });
second.end('CD');
let secondSettled = false;
void secondResult.finally(() => {
secondSettled = true;
});
await Promise.resolve();
assert.equal(secondSettled, false);
first.end('AB');
assert.deepEqual(await firstResult, { complete: false, offset: 2 });
assert.deepEqual(await secondResult, { complete: true, offset: 4 });
const finalized = await finalizeResumableUpload(uploadId);
fs.rmSync(finalized.tempDir, { recursive: true, force: true });
});

test('expiry aborts an active receive and invalidates the ticket after rollback', async () => {
vi.useFakeTimers();
try {
const bytes = Buffer.from('AB');
const uploadId = beginUpload(bytes).uploadId;
const body = requestStream();
const receiving = receiveResumableUploadChunk({ uploadId, req: body });
body.write('A');
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
await assert.rejects(receiving, /expired/i);
await assert.rejects(finalizeResumableUpload(uploadId), /not found or expired/i);
} finally {
vi.useRealTimers();
}
});

test('an ignored out-of-order chunk does not extend upload expiry', async () => {
vi.useFakeTimers();
try {
const bytes = Buffer.from('AB');
const uploadId = beginUpload(bytes).uploadId;
await vi.advanceTimersByTimeAsync(4 * 60 * 1000);

assert.deepEqual(
await receiveResumableUploadChunk({
uploadId,
req: request(Buffer.from('B'), { 'content-range': 'bytes 1-1/2' }),
}),
{ complete: false, offset: 0 },
);

await vi.advanceTimersByTimeAsync(60 * 1000);
await assert.rejects(finalizeResumableUpload(uploadId), /not found or expired/i);
} finally {
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
vi.useRealTimers();
}
});

test('an ignored un-ranged retry does not extend upload expiry', async () => {
vi.useFakeTimers();
try {
const bytes = Buffer.from('ABC');
const uploadId = beginUpload(bytes).uploadId;
await receiveResumableUploadChunk({
uploadId,
req: request(Buffer.from('A'), { 'content-range': 'bytes 0-0/3' }),
});
await vi.advanceTimersByTimeAsync(4 * 60 * 1000);

assert.deepEqual(
await receiveResumableUploadChunk({ uploadId, req: request(Buffer.from('A')) }),
{ complete: false, offset: 1 },
);

await vi.advanceTimersByTimeAsync(60 * 1000);
await assert.rejects(finalizeResumableUpload(uploadId), /not found or expired/i);
} finally {
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
vi.useRealTimers();
}
});

function beginUpload(bytes: Buffer): ReturnType<typeof beginResumableUpload> {
return beginResumableUpload({
baseUrl: 'http://127.0.0.1:1234',
tokenHeaders: {},
uploadAttemptId: crypto.randomUUID(),
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
fileName: 'artifact.bin',
sizeBytes: bytes.length,
artifactType: 'file',
});
}

function request(body: Buffer, headers: Record<string, string> = {}): IncomingMessage {
return Object.assign(Readable.from(body), { headers }) as IncomingMessage;
}

function requestStream(headers: Record<string, string> = {}): PassThrough & IncomingMessage {
return Object.assign(new PassThrough(), { headers }) as PassThrough & IncomingMessage;
}
9 changes: 8 additions & 1 deletion src/daemon/__tests__/upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import path from 'node:path';
import { Readable } from 'node:stream';
import type { IncomingMessage } from 'node:http';
import { receiveUpload } from '../upload.ts';
import { streamReadableToFile } from '../artifact-download.ts';
import { streamReadableToFile, validateArtifactContentLength } from '../artifact-download.ts';
import { runCmdSync } from '../../utils/exec.ts';
import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts';

Expand All @@ -23,6 +23,13 @@ test('receiveUpload rejects uploads that exceed the configured content-length li
await assert.rejects(async () => await receiveUpload(req), /Upload exceeds maximum size/i);
});

test('artifact content-length accepts decimal integers only', () => {
for (const value of ['+1', '1e3', '0x10', '1.5', '-1']) {
assert.throws(() => validateArtifactContentLength(value), value);
}
assert.doesNotThrow(() => validateArtifactContentLength('0'));
});

test('receiveUpload rejects app bundle archives containing symlinks', async () => {
const tempRoot = mkdtempForTestSync('agent-device-upload-archive-');
const appDir = path.join(tempRoot, 'Sample.app');
Expand Down
Loading
Loading