mobile-ai-agents 1.0.39 → 1.0.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/cli/commands/network.js +10 -0
- package/cli/index.js +1 -0
- package/cli/lib/api-contract.js +279 -0
- package/cli/schemas/api-contract.schema.json +90 -0
- package/docs/api-contracts.md +76 -0
- package/docs/website-api-discovery.md +2 -0
- package/docs/wiki.md +5 -0
- package/package.json +1 -1
- package/skills/cross-platform/web-network-to-mobile.md +2 -0
package/README.md
CHANGED
|
@@ -133,6 +133,8 @@ This delivers browser API evidence. Mobile code generation and verified app/API
|
|
|
133
133
|
|
|
134
134
|
[Website API discovery guide: setup, demo, privacy and mobile handoff](docs/website-api-discovery.md) · [Implementation issue #69](https://github.com/salmanashraf/mobile-ai-agents/issues/69)
|
|
135
135
|
|
|
136
|
+
**Next: turn browser evidence into a mobile API contract.** Run `npx mobile-ai-agents@latest network contract --capture /tmp/mobile-api-demo/capture.json --out /tmp/mobile-api-contract-1`. Import HAR with `--har`, or cross-check local OpenAPI JSON with `--openapi`. The output separates observations, documented requirements and unconfirmed inferences; it flags gaps and conflicts while keeping mobile parity unverified. [Contract guide](docs/api-contracts.md). Available starting in `mobile-ai-agents@1.0.40`.
|
|
137
|
+
|
|
136
138
|
Available in `mobile-ai-agents@1.0.39` and newer. Use `npx mobile-ai-agents@latest network capture --url URL --out NEW_DIRECTORY` for your own authorized site, with optional Playwright installed. Use a new output directory for every run.
|
|
137
139
|
|
|
138
140
|
---
|
package/cli/commands/network.js
CHANGED
|
@@ -3,9 +3,19 @@ const fs = require('node:fs');
|
|
|
3
3
|
const path = require('node:path');
|
|
4
4
|
const { parseOptions } = require('../lib/options');
|
|
5
5
|
const { capture } = require('../lib/network-capture');
|
|
6
|
+
const { readJson, buildContract, writeContract } = require('../lib/api-contract');
|
|
6
7
|
|
|
7
8
|
async function cmdNetwork(args) {
|
|
8
9
|
const opts = parseOptions(args);
|
|
10
|
+
if (opts._.join(' ') === 'contract') {
|
|
11
|
+
if ((!opts.capture && !opts.har) || (opts.capture && opts.har) || !opts.out || Object.keys(opts).some(key => !['_', 'capture', 'har', 'openapi', 'out'].includes(key))) throw new Error('Usage: network contract --capture capture.json | --har session.har --out NEW_DIRECTORY [--openapi openapi.json]');
|
|
12
|
+
for (const key of Object.keys(opts)) if (key !== '_' && typeof opts[key] !== 'string') throw new Error(`--${key} needs a value.`);
|
|
13
|
+
const contract = buildContract(readJson(opts.capture || opts.har), { format: opts.har ? 'har' : 'capture', openapi: opts.openapi ? readJson(opts.openapi) : undefined });
|
|
14
|
+
writeContract(contract, path.resolve(opts.out));
|
|
15
|
+
console.log(`needs-review: ${contract.touchpoints.length} touchpoints, ${contract.requests.length} requests, ${contract.findings.length} findings. ${path.resolve(opts.out)}`);
|
|
16
|
+
console.log('Observed/documented/inferred sources remain separate. Mobile verification: unverified.');
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
9
19
|
if (opts._.join(' ') !== 'capture' || !opts.url || !opts.out || Object.keys(opts).some(key => !['_', 'url', 'out', 'flow', 'allow-origin', 'allow-writes', 'headed', 'channel'].includes(key))) {
|
|
10
20
|
throw new Error('Usage: mobile-ai-agents network capture --url URL --out NEW_DIRECTORY [--flow flow.json] [--allow-origin ORIGIN,ORIGIN] [--allow-writes] [--headed] [--channel chrome]');
|
|
11
21
|
}
|
package/cli/index.js
CHANGED
|
@@ -1418,6 +1418,7 @@ function cmdHelp() {
|
|
|
1418
1418
|
console.log(` ${bold('install')} Install agents, skills, and workflows`);
|
|
1419
1419
|
console.log(` ${bold('add')} agent|skill|workflow <name> Install a single item`);
|
|
1420
1420
|
console.log(` ${bold('network capture')} --url URL --out DIR Capture website API touchpoints (optional Playwright)`);
|
|
1421
|
+
console.log(` ${bold('network contract')} --capture JSON|--har HAR --out DIR [--openapi JSON] Export mobile API handoff`);
|
|
1421
1422
|
console.log(` ${bold('memory')} init|capture|search|... Local Mobile Memory store`);
|
|
1422
1423
|
console.log(` ${bold('doctor')} --platform android Check SDK, adb, emulator, MCP, and project config`);
|
|
1423
1424
|
console.log(` ${bold('harness')} policy init|validate Configure governed Mobile Harness runs`);
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { createHash } = require('node:crypto');
|
|
6
|
+
const { safeUrl, bodyShape } = require('./network-capture');
|
|
7
|
+
|
|
8
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
9
|
+
const id = value => typeof value === 'string' && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value);
|
|
10
|
+
const enumValue = (value, allowed, fallback) => allowed.includes(value) ? value : fallback;
|
|
11
|
+
const mediaType = value => typeof value === 'string' && /^[\w.+-]+\/[\w.+*-]+(?:;|$)/.test(value) ? value.split(';')[0].toLowerCase() : null;
|
|
12
|
+
|
|
13
|
+
function readJson(file) {
|
|
14
|
+
if (fs.statSync(file).size > 10 * 1024 * 1024) throw new Error('Input exceeds the 10 MiB contract-processing limit.');
|
|
15
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); }
|
|
16
|
+
catch { throw new Error('Input must be valid JSON; parser details omitted to avoid exposing source values.'); }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Copy only our shape vocabulary; never trust an imported object to be sanitized.
|
|
20
|
+
function observedShape(value, depth = 0) {
|
|
21
|
+
if (!object(value) || depth > 12) return { type: 'unknown' };
|
|
22
|
+
const type = enumValue(value.type, ['object', 'array', 'string', 'number', 'boolean', 'null', 'binary'], 'unknown');
|
|
23
|
+
if (type === 'object') {
|
|
24
|
+
const entries = Object.entries(object(value.properties) ? value.properties : {});
|
|
25
|
+
return { type, properties: Object.fromEntries(entries.slice(0, 200).map(([key, child]) => [key, observedShape(child, depth + 1)])), ...(entries.length > 200 ? { sampled: true } : {}) };
|
|
26
|
+
}
|
|
27
|
+
if (type === 'array') return { type, observedItems: (Array.isArray(value.observedItems) ? value.observedItems : []).slice(0, 20).map(child => observedShape(child, depth + 1)), sampled: value.sampled === true || value.observedItems?.length > 20 };
|
|
28
|
+
return { type };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function observedBody(value) {
|
|
32
|
+
const state = enumValue(value?.state, ['observed', 'empty', 'omitted', 'unavailable'], 'unavailable');
|
|
33
|
+
return { source: 'observed', state, ...(state === 'observed' ? { schema: observedShape(value.schema) } : {}) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function headers(value) {
|
|
37
|
+
return Object.fromEntries(Object.entries(object(value) ? value : {}).filter(([key]) => /^[A-Za-z0-9-]{1,100}$/.test(key)).map(([key, item]) => [key.toLowerCase(), ['content-type', 'accept'].includes(key.toLowerCase()) ? mediaType(item) || '[OMITTED]' : '[REDACTED]']));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function harHeaders(items) {
|
|
41
|
+
return headers(Object.fromEntries((Array.isArray(items) ? items : []).filter(item => typeof item.name === 'string').map(item => [item.name, item.value])));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fromHar(har) {
|
|
45
|
+
if (!object(har.log) || !Array.isArray(har.log.entries) || har.log.entries.length > 500) throw new Error('HAR requires log.entries with at most 500 HTTP transactions.');
|
|
46
|
+
const touchpoints = [{ id: 'T0', action: 'unknown', status: 'unverified', requestIds: [] }];
|
|
47
|
+
const requests = har.log.entries.map((entry, index) => {
|
|
48
|
+
if (!object(entry.request) || typeof entry.request.url !== 'string') throw new Error('HAR transaction lacks request URL.');
|
|
49
|
+
const requestHeaders = harHeaders(entry.request.headers);
|
|
50
|
+
const responseHeaders = harHeaders(entry.response?.headers);
|
|
51
|
+
const post = entry.request.postData;
|
|
52
|
+
if (!requestHeaders['content-type'] && mediaType(post?.mimeType)) requestHeaders['content-type'] = mediaType(post.mimeType);
|
|
53
|
+
if (!responseHeaders['content-type'] && mediaType(entry.response?.content?.mimeType)) responseHeaders['content-type'] = mediaType(entry.response.content.mimeType);
|
|
54
|
+
let requestBody = post ? bodyShape(post.text, post.mimeType || requestHeaders['content-type']) : { state: 'empty' };
|
|
55
|
+
if (post && /multipart\/form-data|application\/x-www-form-urlencoded/i.test(post.mimeType || '') && Array.isArray(post.params)) {
|
|
56
|
+
requestBody = { state: 'observed', schema: { type: 'object', properties: Object.fromEntries(post.params.slice(0, 200).filter(item => typeof item.name === 'string').map(item => [item.name, { type: item.fileName !== undefined ? 'binary' : 'string' }])) } };
|
|
57
|
+
}
|
|
58
|
+
const content = entry.response?.content;
|
|
59
|
+
let responseBody = { state: 'unavailable' };
|
|
60
|
+
if ([204, 304].includes(entry.response?.status) || entry.request.method === 'HEAD') responseBody = { state: 'empty' };
|
|
61
|
+
if (content && typeof content.text === 'string') {
|
|
62
|
+
if (content.encoding && content.encoding !== 'base64') responseBody = { state: 'unavailable' };
|
|
63
|
+
else if (content.text.length > 90000) responseBody = { state: 'omitted' };
|
|
64
|
+
else responseBody = bodyShape(content.encoding === 'base64' ? Buffer.from(content.text, 'base64').toString('utf8') : content.text, content.mimeType || responseHeaders['content-type']);
|
|
65
|
+
}
|
|
66
|
+
const requestId = `R${index + 1}`;
|
|
67
|
+
touchpoints[0].requestIds.push(requestId);
|
|
68
|
+
return { id: requestId, touchpoint: 'T0', method: entry.request.method, url: entry.request.url, requestHeaders, responseHeaders, requestBody, responseBody, status: entry.response?.status, outcome: entry.response?.status > 0 ? 'http-response' : 'failed' };
|
|
69
|
+
});
|
|
70
|
+
return { schemaVersion: 1, status: 'partial', touchpoints, requests };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeCapture(input) {
|
|
74
|
+
if (input?.schemaVersion !== 1 || !Array.isArray(input.touchpoints) || !Array.isArray(input.requests) || input.touchpoints.length > 100 || input.requests.length > 500) throw new Error('Expected capture schemaVersion 1 with bounded touchpoints and requests.');
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
const points = input.touchpoints.map(point => {
|
|
77
|
+
if (!id(point.id) || seen.has(point.id) || !Array.isArray(point.requestIds)) throw new Error('Invalid or duplicate touchpoint ID.');
|
|
78
|
+
seen.add(point.id);
|
|
79
|
+
return { id: point.id, action: enumValue(point.action, ['navigate', 'click', 'fill'], 'unknown'), status: enumValue(point.status, ['completed', 'failed', 'running'], 'unverified'), requestIds: [...point.requestIds] };
|
|
80
|
+
});
|
|
81
|
+
const requests = new Set();
|
|
82
|
+
const rows = input.requests.map((row, index) => {
|
|
83
|
+
if (!id(row.id) || requests.has(row.id) || !seen.has(row.touchpoint)) throw new Error('Invalid request ID or missing touchpoint attribution.');
|
|
84
|
+
if (typeof row.method !== 'string' || !/^[A-Z]{1,16}$/.test(row.method)) throw new Error('Request has invalid HTTP method.');
|
|
85
|
+
if (!/^https?:$/.test(new URL(row.url).protocol)) throw new Error('Only HTTP(S) capture URLs are supported.');
|
|
86
|
+
requests.add(row.id);
|
|
87
|
+
return {
|
|
88
|
+
id: row.id, touchpointId: row.touchpoint, sequence: index + 1,
|
|
89
|
+
source: 'observed', method: row.method, url: safeUrl(row.url),
|
|
90
|
+
requestHeaders: headers(row.requestHeaders), responseHeaders: headers(row.responseHeaders),
|
|
91
|
+
request: observedBody(row.requestBody), response: observedBody(row.responseBody),
|
|
92
|
+
status: Number.isInteger(row.status) && row.status >= 100 && row.status <= 599 ? row.status : null,
|
|
93
|
+
outcome: enumValue(row.outcome, ['http-success', 'http-error', 'http-response', 'failed', 'pending'], 'unknown'),
|
|
94
|
+
redirectedFrom: id(row.redirectedFrom) ? row.redirectedFrom : null,
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
const assigned = new Set();
|
|
98
|
+
for (const point of points) for (const requestId of point.requestIds) {
|
|
99
|
+
if (!requests.has(requestId) || assigned.has(requestId) || rows.find(row => row.id === requestId).touchpointId !== point.id) throw new Error('Touchpoint request mapping is incomplete or inconsistent.');
|
|
100
|
+
assigned.add(requestId);
|
|
101
|
+
}
|
|
102
|
+
if (assigned.size !== requests.size) throw new Error('Every request needs exactly one touchpoint mapping.');
|
|
103
|
+
for (const row of rows) if (row.redirectedFrom && (!requests.has(row.redirectedFrom) || rows.find(parent => parent.id === row.redirectedFrom).sequence >= row.sequence)) throw new Error('Redirect dependency must reference an earlier request.');
|
|
104
|
+
return { points, rows };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolveRef(value, doc, seen = new Set()) {
|
|
108
|
+
if (!object(value) || !value.$ref) return value;
|
|
109
|
+
if (typeof value.$ref !== 'string' || !value.$ref.startsWith('#/') || seen.has(value.$ref)) return null;
|
|
110
|
+
seen.add(value.$ref);
|
|
111
|
+
const found = value.$ref.slice(2).split('/').reduce((node, key) => object(node) && Object.hasOwn(node, key.replace(/~1/g, '/').replace(/~0/g, '~')) ? node[key.replace(/~1/g, '/').replace(/~0/g, '~')] : null, doc);
|
|
112
|
+
return resolveRef(found, doc, seen);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function documentedShape(value, doc, depth = 0) {
|
|
116
|
+
const resolved = resolveRef(value, doc);
|
|
117
|
+
if (!object(resolved) || depth > 12) return { type: 'unknown', unresolved: true };
|
|
118
|
+
const types = (Array.isArray(resolved.type) ? resolved.type : [resolved.type]).filter(type => ['object', 'array', 'string', 'integer', 'number', 'boolean', 'null'].includes(type));
|
|
119
|
+
if (resolved.nullable === true && !types.includes('null')) types.push('null');
|
|
120
|
+
const result = { types, source: 'documented' };
|
|
121
|
+
if (types.includes('object') || resolved.properties) {
|
|
122
|
+
result.properties = Object.fromEntries(Object.entries(resolved.properties || {}).slice(0, 200).map(([key, child]) => [key, documentedShape(child, doc, depth + 1)]));
|
|
123
|
+
result.required = Array.isArray(resolved.required) ? resolved.required.filter(key => typeof key === 'string') : [];
|
|
124
|
+
}
|
|
125
|
+
if (resolved.items) result.items = documentedShape(resolved.items, doc, depth + 1);
|
|
126
|
+
if (Array.isArray(resolved.enum)) result.enumCount = resolved.enum.length;
|
|
127
|
+
if (typeof resolved.format === 'string' && /^[A-Za-z0-9_.-]{1,40}$/.test(resolved.format)) result.format = resolved.format;
|
|
128
|
+
if (typeof resolved['x-unit'] === 'string' && /^[A-Za-z%][A-Za-z /._%-]{0,31}$/.test(resolved['x-unit'])) result.unit = resolved['x-unit'];
|
|
129
|
+
if (['oneOf', 'anyOf', 'allOf', 'not', 'if', 'pattern', 'additionalProperties', 'const', 'minimum', 'maximum'].some(key => resolved[key] !== undefined)) result.complexConstraintsUnverified = true;
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function compareShapes(observed, documented, pointer = '$', findings = []) {
|
|
134
|
+
if (!observed || !documented || documented.unresolved) return findings;
|
|
135
|
+
const types = documented.types || [];
|
|
136
|
+
if (types.length && !types.includes(observed.type) && !(observed.type === 'number' && types.includes('integer')) && observed.type !== 'unknown') findings.push({ code: 'type-disagreement', field: pointer, observedType: observed.type, documentedTypes: types });
|
|
137
|
+
// Observed number may be integer: scalar values were removed, so do not assert mismatch.
|
|
138
|
+
if (observed.type === 'object') {
|
|
139
|
+
for (const key of documented.required || []) if (!Object.hasOwn(observed.properties || {}, key)) findings.push({ code: 'documented-required-field-absent', field: `${pointer}.${key}` });
|
|
140
|
+
for (const [key, item] of Object.entries(observed.properties || {})) if (documented.properties && Object.hasOwn(documented.properties, key)) compareShapes(item, documented.properties[key], `${pointer}.${key}`, findings);
|
|
141
|
+
}
|
|
142
|
+
if (observed.type === 'array' && documented.items) for (const item of observed.observedItems || []) compareShapes(item, documented.items, `${pointer}[]`, findings);
|
|
143
|
+
return findings;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function matchOperation(row, doc) {
|
|
147
|
+
const url = new URL(row.url);
|
|
148
|
+
const matches = [];
|
|
149
|
+
for (const [template, item] of Object.entries(doc.paths || {})) {
|
|
150
|
+
if (!template.startsWith('/') || !object(item) || !object(item[row.method.toLowerCase()])) continue;
|
|
151
|
+
const operation = item[row.method.toLowerCase()];
|
|
152
|
+
const servers = operation.servers || item.servers || doc.servers || [{ url: '/' }];
|
|
153
|
+
for (const server of servers) {
|
|
154
|
+
if (typeof server.url !== 'string' || /\{/.test(server.url)) continue;
|
|
155
|
+
let base;
|
|
156
|
+
try { base = new URL(server.url, url.origin); } catch { continue; }
|
|
157
|
+
const candidate = (base.pathname.replace(/\/$/, '') + template).split('/');
|
|
158
|
+
const actual = url.pathname.split('/');
|
|
159
|
+
if (base.origin !== url.origin || candidate.length !== actual.length) continue;
|
|
160
|
+
if (candidate.every((part, index) => /^\{[^}]+\}$/.test(part) || part === actual[index])) {
|
|
161
|
+
matches.push({ operation, template, item }); break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Literal paths have precedence over matching template paths per OpenAPI.
|
|
166
|
+
const literal = matches.filter(match => !match.template.includes('{'));
|
|
167
|
+
return (literal.length === 1 ? literal : matches).length === 1 ? (literal.length === 1 ? literal : matches)[0] : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function documentedBody(container, type, doc) {
|
|
171
|
+
const resolved = resolveRef(container, doc);
|
|
172
|
+
if (container?.$ref && !resolved) return { state: 'unresolved' };
|
|
173
|
+
if (!object(resolved?.content)) return { state: 'undocumented' };
|
|
174
|
+
const entry = Object.entries(resolved.content).find(([key]) => key === type) || Object.entries(resolved.content).find(([key]) => key === '*/*' || key.endsWith('/*') && type?.startsWith(key.slice(0, -1)));
|
|
175
|
+
if (!entry) return { state: 'media-type-unmatched' };
|
|
176
|
+
return { state: 'documented', mediaType: mediaType(entry[0]), schema: documentedShape(entry[1]?.schema, doc) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function schemaUnverified(schema) {
|
|
180
|
+
return !!schema && (schema.unresolved || schema.complexConstraintsUnverified || Object.values(schema.properties || {}).some(schemaUnverified) || schemaUnverified(schema.items));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function buildContract(input, { format = 'capture', openapi } = {}) {
|
|
184
|
+
if (!['capture', 'har'].includes(format)) throw new Error('format must be capture or har.');
|
|
185
|
+
if (openapi && (!/^3\.[01]\./.test(openapi.openapi || '') || !object(openapi.paths))) throw new Error('Cross-check requires an OpenAPI 3.0 or 3.1 JSON document with paths.');
|
|
186
|
+
const { points, rows } = normalizeCapture(format === 'har' ? fromHar(input) : input);
|
|
187
|
+
const findings = [];
|
|
188
|
+
const add = (code, requestId = null) => findings.push({ code, requestId });
|
|
189
|
+
if (format === 'capture' && input.status !== 'captured') add('capture-incomplete-or-status-unknown');
|
|
190
|
+
if (format === 'har' && rows.length) add('har-action-provenance-missing');
|
|
191
|
+
for (const row of rows) {
|
|
192
|
+
row.unknowns = ['requiredness', 'enum-coverage', 'units', 'pagination-semantics', 'complete-error-coverage', 'mobile-auth-contract'];
|
|
193
|
+
const names = Object.keys(row.requestHeaders);
|
|
194
|
+
row.mobileAuth = { source: 'inferred', status: 'needs-confirmation', signals: [] };
|
|
195
|
+
if (names.includes('cookie')) row.mobileAuth.signals.push('cookie-header-observed');
|
|
196
|
+
if (names.some(key => /csrf|xsrf/.test(key))) row.mobileAuth.signals.push('csrf-header-observed');
|
|
197
|
+
if (/\/bff(?:\/|\?|$)/i.test(row.url)) row.mobileAuth.signals.push('bff-path-candidate');
|
|
198
|
+
if (row.mobileAuth.signals.length) add('mobile-auth-handoff-required', row.id);
|
|
199
|
+
const query = [...new URL(row.url).searchParams.keys()];
|
|
200
|
+
row.paginationCandidates = { source: 'inferred', names: query.filter(key => /^(page|cursor|offset|limit|after|before)$/i.test(key)), status: 'unverified' };
|
|
201
|
+
row.transport = { request: mediaType(row.requestHeaders['content-type']), response: mediaType(row.responseHeaders['content-type']) };
|
|
202
|
+
row.errorEvidence = { source: 'observed', httpError: row.status !== null && row.status >= 400, graphqlErrorsField: !!row.response.schema?.properties?.errors };
|
|
203
|
+
if (row.request.schema?.properties?.query || row.request.schema?.properties?.operationName || row.errorEvidence.graphqlErrorsField) add('graphql-operation-values-and-semantics-unverified', row.id);
|
|
204
|
+
if (row.transport.request === 'multipart/form-data') add('multipart-upload-stages-unverified', row.id);
|
|
205
|
+
if (row.transport.response === 'text/event-stream') add('stream-frames-unavailable', row.id);
|
|
206
|
+
if (row.response.state === 'unavailable' || row.response.state === 'omitted') add('response-body-coverage-gap', row.id);
|
|
207
|
+
if (row.status === null) add('response-status-unavailable', row.id);
|
|
208
|
+
row.dependencies = { source: 'observed', redirectedFrom: row.redirectedFrom, causalDependencies: 'unverified', ordering: 'capture-order-only' };
|
|
209
|
+
delete row.redirectedFrom;
|
|
210
|
+
if (openapi) {
|
|
211
|
+
const match = matchOperation(row, openapi);
|
|
212
|
+
if (!match) { row.documentation = { source: 'documented', state: 'operation-unmatched-or-ambiguous' }; add('openapi-operation-unmatched-or-ambiguous', row.id); }
|
|
213
|
+
else {
|
|
214
|
+
const response = match.operation.responses?.[String(row.status)] || match.operation.responses?.[`${String(row.status)[0]}XX`] || match.operation.responses?.default;
|
|
215
|
+
const parameterMap = new Map();
|
|
216
|
+
for (const raw of [...(Array.isArray(match.item.parameters) ? match.item.parameters : []), ...(Array.isArray(match.operation.parameters) ? match.operation.parameters : [])]) {
|
|
217
|
+
const parameter = resolveRef(raw, openapi);
|
|
218
|
+
if (!object(parameter) || typeof parameter.name !== 'string' || !['query', 'header', 'path', 'cookie'].includes(parameter.in)) { add('openapi-parameter-unresolved', row.id); continue; }
|
|
219
|
+
parameterMap.set(`${parameter.in}:${parameter.name}`, { name: parameter.name, in: parameter.in, required: typeof parameter.required === 'boolean' ? parameter.required : null, schema: documentedShape(parameter.schema, openapi) });
|
|
220
|
+
}
|
|
221
|
+
const parameters = [...parameterMap.values()];
|
|
222
|
+
for (const parameter of parameters) if (parameter.required && ((parameter.in === 'query' && !query.includes(parameter.name)) || (parameter.in === 'header' && !names.includes(parameter.name.toLowerCase())))) findings.push({ code: 'documented-required-parameter-absent', requestId: row.id, field: parameter.name });
|
|
223
|
+
row.documentation = { source: 'documented', state: 'operation-matched', path: match.template, parameters, request: documentedBody(match.operation.requestBody, row.transport.request, openapi), response: documentedBody(response, row.transport.response, openapi) };
|
|
224
|
+
const bodyDefinition = resolveRef(match.operation.requestBody, openapi);
|
|
225
|
+
row.documentation.request.required = typeof bodyDefinition?.required === 'boolean' ? bodyDefinition.required : null;
|
|
226
|
+
if (bodyDefinition?.required === true && row.request.state === 'empty') add('documented-required-request-body-absent', row.id);
|
|
227
|
+
for (const kind of ['request', 'response']) {
|
|
228
|
+
const documented = row.documentation[kind];
|
|
229
|
+
if (documented.state === 'unresolved' || schemaUnverified(documented.schema)) add(`openapi-${kind}-constraints-unverified`, row.id);
|
|
230
|
+
if (documented.state === 'documented' && row[kind].state === 'observed') for (const finding of compareShapes(row[kind].schema, documented.schema)) findings.push({ ...finding, requestId: row.id, body: kind });
|
|
231
|
+
if (documented.state === 'media-type-unmatched') add(`openapi-${kind}-media-type-unmatched`, row.id);
|
|
232
|
+
}
|
|
233
|
+
if (!response && row.status !== null) add('openapi-status-undocumented', row.id);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const contract = {
|
|
238
|
+
contractVersion: '1.0.0', source: { format, ...(format === 'capture' ? { captureStatus: enumValue(input.status, ['captured', 'partial', 'failed'], 'unknown') } : {}), sha256: createHash('sha256').update(JSON.stringify(input)).digest('hex'), ...(openapi ? { openapiSha256: createHash('sha256').update(JSON.stringify(openapi)).digest('hex') } : {}) },
|
|
239
|
+
status: 'needs-review', mobileVerification: 'unverified',
|
|
240
|
+
touchpoints: points.map(point => ({ ...point, provenance: format === 'har' ? 'unattributed' : 'capture-window', outcome: point.status === 'completed' ? 'browser-action-completed-only' : 'unverified' })),
|
|
241
|
+
requests: rows, findings,
|
|
242
|
+
limitations: [
|
|
243
|
+
'Observed shapes are samples, not required fields, complete enums, units or proven API semantics.',
|
|
244
|
+
'Temporal ordering and redirects do not prove other causal dependencies or UI/business outcomes.',
|
|
245
|
+
'Raw values, examples, enum values, descriptions and arbitrary metadata are omitted; field names, origins and readable routes remain visible.',
|
|
246
|
+
'HAR has no reliable touchpoint attribution; pageref and initiator metadata do not establish which action caused a call.',
|
|
247
|
+
'OpenAPI cross-check is a subset, not full schema validation: local refs and basic types/requiredness only; composition/constraints remain unverified. Relative servers use the captured origin.',
|
|
248
|
+
'No external refs are fetched. Browser-only auth, GraphQL semantics, upload stages and streaming need a supported mobile contract.',
|
|
249
|
+
],
|
|
250
|
+
};
|
|
251
|
+
validateContract(contract);
|
|
252
|
+
return contract;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function validateContract(contract) {
|
|
256
|
+
if (contract?.contractVersion !== '1.0.0' || contract.status !== 'needs-review' || contract.mobileVerification !== 'unverified' || !['capture', 'har'].includes(contract.source?.format) || !/^[a-f0-9]{64}$/.test(contract.source?.sha256 || '') || !Array.isArray(contract.touchpoints) || !Array.isArray(contract.requests)) throw new Error('Invalid API contract envelope.');
|
|
257
|
+
const points = new Set(contract.touchpoints.map(point => point.id));
|
|
258
|
+
if (points.size !== contract.touchpoints.length || contract.touchpoints.some(point => !id(point.id) || !Array.isArray(point.requestIds))) throw new Error('Invalid contract touchpoints.');
|
|
259
|
+
const ids = new Set(contract.requests.map(row => row.id));
|
|
260
|
+
if (ids.size !== contract.requests.length || contract.requests.some(row => !id(row.id) || !points.has(row.touchpointId))) throw new Error('Duplicate or invalid contract request IDs.');
|
|
261
|
+
const assignments = contract.touchpoints.flatMap(point => point.requestIds);
|
|
262
|
+
if (new Set(assignments).size !== ids.size || assignments.length !== ids.size || assignments.some(item => !ids.has(item))) throw new Error('Contract traceability is incomplete.');
|
|
263
|
+
for (const point of contract.touchpoints) if (point.requestIds.some(requestId => contract.requests.find(row => row.id === requestId).touchpointId !== point.id)) throw new Error('Contract touchpoint mapping is inconsistent.');
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function writeContract(contract, directory) {
|
|
268
|
+
validateContract(contract);
|
|
269
|
+
fs.mkdirSync(directory, { mode: 0o700 });
|
|
270
|
+
fs.writeFileSync(path.join(directory, 'contract.json'), JSON.stringify(contract, null, 2) + '\n', { mode: 0o600 });
|
|
271
|
+
const text = ['# Mobile API handoff contract', '', 'Status: needs-review. Mobile verification: unverified.', '', '## Touchpoints', ''];
|
|
272
|
+
for (const point of contract.touchpoints) text.push(`- ${point.id}: ${point.action}; ${point.requestIds.join(', ') || 'no request observed'}; ${point.provenance}.`);
|
|
273
|
+
text.push('', '## Requests', '');
|
|
274
|
+
for (const row of contract.requests) text.push(`- ${row.id} → ${row.touchpointId}: ${row.method} ${row.url.replace(/[\r\n`<>]/g, '')}; HTTP ${row.status ?? 'unavailable'}; request ${row.request.state}; response ${row.response.state}.`);
|
|
275
|
+
text.push('', '## Findings', '', ...contract.findings.map(item => `- ${item.requestId || 'Source'}: ${item.code}${item.field ? ` (${item.field.replace(/[\r\n`<>]/g, '')})` : ''}`), '', '## Limitations', '', ...contract.limitations.map(item => `- ${item}`));
|
|
276
|
+
fs.writeFileSync(path.join(directory, 'contract.md'), text.join('\n') + '\n', { mode: 0o600 });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
module.exports = { readJson, observedShape, fromHar, normalizeCapture, resolveRef, documentedShape, compareShapes, buildContract, validateContract, writeContract };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/salmanashraf/mobile-ai-agents/blob/main/cli/schemas/api-contract.schema.json",
|
|
4
|
+
"title": "Observed mobile API handoff contract v1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["contractVersion", "source", "status", "mobileVerification", "touchpoints", "requests", "findings", "limitations"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"contractVersion": { "const": "1.0.0" },
|
|
10
|
+
"source": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"required": ["format", "sha256"],
|
|
13
|
+
"properties": {
|
|
14
|
+
"format": { "enum": ["capture", "har"] },
|
|
15
|
+
"captureStatus": { "enum": ["captured", "partial", "failed", "unknown"] },
|
|
16
|
+
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
|
17
|
+
"openapiSha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
|
|
18
|
+
},
|
|
19
|
+
"additionalProperties": false
|
|
20
|
+
},
|
|
21
|
+
"status": { "const": "needs-review" },
|
|
22
|
+
"mobileVerification": { "const": "unverified" },
|
|
23
|
+
"touchpoints": {
|
|
24
|
+
"type": "array",
|
|
25
|
+
"maxItems": 100,
|
|
26
|
+
"items": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"required": ["id", "action", "status", "requestIds", "provenance", "outcome"],
|
|
29
|
+
"properties": {
|
|
30
|
+
"id": { "$ref": "#/$defs/id" },
|
|
31
|
+
"action": { "enum": ["navigate", "click", "fill", "unknown"] },
|
|
32
|
+
"status": { "enum": ["completed", "failed", "running", "unverified"] },
|
|
33
|
+
"requestIds": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/id" } },
|
|
34
|
+
"provenance": { "enum": ["capture-window", "unattributed"] },
|
|
35
|
+
"outcome": { "enum": ["browser-action-completed-only", "unverified"] }
|
|
36
|
+
},
|
|
37
|
+
"additionalProperties": false
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"requests": {
|
|
41
|
+
"type": "array",
|
|
42
|
+
"maxItems": 500,
|
|
43
|
+
"items": {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"required": ["id", "touchpointId", "sequence", "source", "method", "url", "requestHeaders", "responseHeaders", "request", "response", "status", "outcome", "unknowns", "mobileAuth", "paginationCandidates", "transport", "errorEvidence", "dependencies"],
|
|
46
|
+
"properties": {
|
|
47
|
+
"id": { "$ref": "#/$defs/id" },
|
|
48
|
+
"touchpointId": { "$ref": "#/$defs/id" },
|
|
49
|
+
"sequence": { "type": "integer", "minimum": 1 },
|
|
50
|
+
"source": { "const": "observed" },
|
|
51
|
+
"method": { "type": "string", "pattern": "^[A-Z]{1,16}$" },
|
|
52
|
+
"url": { "type": "string", "pattern": "^https?://" },
|
|
53
|
+
"requestHeaders": { "type": "object", "additionalProperties": { "type": "string" } },
|
|
54
|
+
"responseHeaders": { "type": "object", "additionalProperties": { "type": "string" } },
|
|
55
|
+
"request": { "$ref": "#/$defs/body" },
|
|
56
|
+
"response": { "$ref": "#/$defs/body" },
|
|
57
|
+
"status": { "type": ["integer", "null"], "minimum": 100, "maximum": 599 },
|
|
58
|
+
"outcome": { "enum": ["http-success", "http-error", "http-response", "failed", "pending", "unknown"] },
|
|
59
|
+
"unknowns": { "type": "array", "items": { "type": "string" } },
|
|
60
|
+
"mobileAuth": { "type": "object", "required": ["source", "status", "signals"], "properties": { "source": { "const": "inferred" }, "status": { "const": "needs-confirmation" }, "signals": { "type": "array", "items": { "type": "string" } } } },
|
|
61
|
+
"paginationCandidates": { "type": "object", "required": ["source", "names", "status"], "properties": { "source": { "const": "inferred" }, "names": { "type": "array", "items": { "type": "string" } }, "status": { "const": "unverified" } } },
|
|
62
|
+
"transport": { "type": "object", "required": ["request", "response"], "additionalProperties": { "type": ["string", "null"] } },
|
|
63
|
+
"errorEvidence": { "type": "object", "required": ["source", "httpError", "graphqlErrorsField"], "properties": { "source": { "const": "observed" }, "httpError": { "type": "boolean" }, "graphqlErrorsField": { "type": "boolean" } } },
|
|
64
|
+
"dependencies": { "type": "object", "required": ["source", "redirectedFrom", "causalDependencies", "ordering"], "properties": { "source": { "const": "observed" }, "redirectedFrom": { "type": ["string", "null"] }, "causalDependencies": { "const": "unverified" }, "ordering": { "const": "capture-order-only" } } },
|
|
65
|
+
"documentation": { "type": "object", "required": ["source", "state"], "properties": { "source": { "const": "documented" }, "state": { "enum": ["operation-matched", "operation-unmatched-or-ambiguous"] }, "path": { "type": "string" }, "request": { "type": "object" }, "response": { "type": "object" } } }
|
|
66
|
+
},
|
|
67
|
+
"additionalProperties": false
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"findings": { "type": "array", "items": { "type": "object", "required": ["code", "requestId"], "properties": { "code": { "type": "string" }, "requestId": { "type": ["string", "null"] }, "field": { "type": "string" }, "observedType": { "type": "string" }, "documentedTypes": { "type": "array", "items": { "type": "string" } }, "body": { "enum": ["request", "response"] } }, "additionalProperties": false } },
|
|
71
|
+
"limitations": { "type": "array", "items": { "type": "string" } }
|
|
72
|
+
},
|
|
73
|
+
"$defs": {
|
|
74
|
+
"id": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,63}$" },
|
|
75
|
+
"body": {
|
|
76
|
+
"type": "object",
|
|
77
|
+
"required": ["source", "state"],
|
|
78
|
+
"properties": { "source": { "const": "observed" }, "state": { "enum": ["observed", "empty", "omitted", "unavailable"] }, "schema": { "$ref": "#/$defs/shape" } },
|
|
79
|
+
"additionalProperties": false,
|
|
80
|
+
"if": { "properties": { "state": { "const": "observed" } } },
|
|
81
|
+
"then": { "required": ["schema"] }
|
|
82
|
+
},
|
|
83
|
+
"shape": {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"required": ["type"],
|
|
86
|
+
"properties": { "type": { "enum": ["object", "array", "string", "number", "boolean", "null", "binary", "unknown"] }, "properties": { "type": "object", "additionalProperties": { "$ref": "#/$defs/shape" } }, "observedItems": { "type": "array", "items": { "$ref": "#/$defs/shape" } }, "sampled": { "type": "boolean" } },
|
|
87
|
+
"additionalProperties": false
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Browser evidence → mobile API handoff contracts
|
|
2
|
+
|
|
3
|
+
The `network contract` command converts captured website traffic into a versioned, traceable mobile handoff. It runs locally without Playwright, a device or a model provider. It never sends requests to the captured site, replays API calls, or generates mobile clients.
|
|
4
|
+
|
|
5
|
+
**Availability:** Included in `mobile-ai-agents@1.0.40` and newer. This follows the [v1.0.39 capture feature](website-api-discovery.md). Implementation is tracked in [#70](https://github.com/salmanashraf/mobile-ai-agents/issues/70).
|
|
6
|
+
|
|
7
|
+
## From the owned demo or an authorized capture
|
|
8
|
+
|
|
9
|
+
Run the [capture quickstart](website-api-discovery.md) to obtain `capture.json`, then:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx mobile-ai-agents@latest network contract \
|
|
13
|
+
--capture /tmp/mobile-api-demo/capture.json \
|
|
14
|
+
--out /tmp/mobile-api-contract-1
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Optionally compare against a local OpenAPI 3.0/3.1 **JSON** document:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx mobile-ai-agents@latest network contract \
|
|
21
|
+
--capture /tmp/mobile-api-demo/capture.json \
|
|
22
|
+
--openapi /path/to/openapi.json \
|
|
23
|
+
--out /tmp/mobile-api-contract-2
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The output contains `contract.json` and `contract.md`. Every output directory must be new; the parent must exist. Files have owner-only permissions where supported. Inputs are limited to 10 MiB, 500 requests, 100 touchpoints, and bounded schema depth/property/item sampling. Unsupported versions or inconsistent request/touchpoint references fail before output is written. A successful command means **export completed**, not schema conformance of the API or verified mobile behavior.
|
|
27
|
+
|
|
28
|
+
## From a HAR export
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx mobile-ai-agents@latest network contract \
|
|
32
|
+
--har /path/to/session.har \
|
|
33
|
+
--out /tmp/mobile-api-har-contract-1
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The importer reads the common HAR `log.entries` transaction layout, header lists, request postData and response content (including base64 JSON). Request cookies, raw values, file names and response strings are not copied into the output. Missing response content remains unavailable. Multipart parameter names/types are preserved when supplied; raw multipart parsing and upload-stage reconstruction are not attempted.
|
|
37
|
+
|
|
38
|
+
HAR does not reliably identify UI actions. All imported transactions belong to an explicitly **unattributed** `T0` touchpoint; neither HAR `pageref` nor request order is treated as a tap/click. Use captured touchpoint evidence when action provenance matters. This importer is a subset reader, not a complete HAR validator.
|
|
39
|
+
|
|
40
|
+
## What the contract says
|
|
41
|
+
|
|
42
|
+
| Evidence | Meaning |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `observed` | Type, status, field presence or header name recorded in supplied traffic |
|
|
45
|
+
| `documented` | Selected OpenAPI fields, kept alongside observations |
|
|
46
|
+
| `inferred` | Auth/pagination candidates requiring confirmation, never established semantics |
|
|
47
|
+
|
|
48
|
+
`contractVersion` is `1.0.0`. The [JSON Schema](../cli/schemas/api-contract.schema.json) describes the output format. Runtime checks additionally enforce unique IDs and complete request-to-touchpoint mappings. Source hashes support correlation; they do not anonymize inputs or prove that two captures represent the same deployment.
|
|
49
|
+
|
|
50
|
+
The contract links touchpoint IDs → ordered request IDs → request/response shapes and status → capture action outcome. Existing capture IDs are retained; HAR request IDs derive from entry order. A completed browser action is not proof of its business outcome. Redirect references are observed links; all other causal dependencies remain unverified.
|
|
51
|
+
|
|
52
|
+
- Presence of a JSON field does not make it required. Seeing `null` records one observed type, not all permitted nullability. A sample number may be integer or fractional; values are omitted, so the comparison does not guess.
|
|
53
|
+
- Enum values, units, pagination behavior and complete error coverage cannot be established from a single sample. Recognized pagination query names are unconfirmed candidates. An explicit OpenAPI `x-unit` is retained as documented metadata, not inferred from values.
|
|
54
|
+
- Cookie/CSRF headers and BFF-like paths flag a mobile-auth handoff. The exporter never converts a captured browser session into mobile credentials or invents a supported mobile endpoint.
|
|
55
|
+
- GraphQL-shaped fields flag an unconfirmed protocol candidate, including an `errors` field in a 200 response. Operation values and GraphQL semantics remain unavailable. Multipart upload stages and socket/SSE frames need additional evidence.
|
|
56
|
+
- Partial/failed captures stay marked in the source metadata. Missing/omitted bodies and unknown status codes produce findings. No evidence gap becomes a pass.
|
|
57
|
+
|
|
58
|
+
## OpenAPI comparison limits
|
|
59
|
+
|
|
60
|
+
This is a focused cross-check, not a full OpenAPI validator or client generator. It matches HTTP methods and paths with server prefixes, local path parameters, literal-path precedence and non-variable server origins. Ambiguous routes are reported. Relative/default servers use the observed origin; server variables, external references and YAML inputs are not resolved.
|
|
61
|
+
|
|
62
|
+
Local JSON Pointer references, basic types, nullability, array item observations, required object fields and required query/header presence are compared. Documented enum counts, selected formats and explicit units can be retained, but enum/example/default/description values are omitted. Composition, numeric constraints, contextual read/write rules, complex parameter serialization and other unimplemented validation remain unverified. Unresolved refs and complex schema constraints produce findings. An unmatched response status or media type is surfaced rather than silently assigned a different contract.
|
|
63
|
+
|
|
64
|
+
Observed facts are not overwritten by documentation. If the response contains a number where documentation says string, both sources remain present and the discrepancy is reported. Confirm whether the API, documentation or sample context needs correction before mobile implementation.
|
|
65
|
+
|
|
66
|
+
## Privacy and mobile handoff
|
|
67
|
+
|
|
68
|
+
The exporter reconstructs an allowlisted shape vocabulary even when supplied capture JSON claims to be sanitized. It removes body scalar values, arbitrary metadata, header/query values and examples. It retains structural identifiers, origins, readable route segments, field names and selected documented metadata; review these before sharing. Keep raw HAR and private OpenAPI/backend files local. This is not a guarantee of anonymous output.
|
|
69
|
+
|
|
70
|
+
Pass the exported contract to [web-network-to-mobile](../skills/cross-platform/web-network-to-mobile.md), resolve findings against the supported backend contract, and implement in the app's existing stack. [#71](https://github.com/salmanashraf/mobile-ai-agents/issues/71) handles platform-preserving implementation; [#72](https://github.com/salmanashraf/mobile-ai-agents/issues/72) handles actual mobile network/UI parity. Every export remains `needs-review` with `mobileVerification: unverified`.
|
|
71
|
+
|
|
72
|
+
## Validation
|
|
73
|
+
|
|
74
|
+
`node --test test/api-contract.test.js` covers provenance, nullable/required-field conflicts, local refs, ambiguous/unsupported documentation, GraphQL errors, multipart metadata, missing HAR bodies, redaction, invalid mappings and CLI output behavior. The real-browser capture regression also exports the owned demo into a contract. No third-party production site or mobile app is contacted by contract processing.
|
|
75
|
+
|
|
76
|
+
Reference formats: [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0.html) and the historical [HAR layout](https://webperfwg.org/specs/HAR/Overview.html). The HAR document is an abandoned historical draft, not presented here as a current W3C standard.
|
|
@@ -83,6 +83,8 @@ Only GET, HEAD and OPTIONS are allowed by default. `--allow-writes` enables othe
|
|
|
83
83
|
|
|
84
84
|
## Mobile integration handoff
|
|
85
85
|
|
|
86
|
+
Starting in v1.0.40, convert captures into a versioned handoff using `npx mobile-ai-agents@latest network contract --capture /tmp/mobile-api-demo/capture.json --out /tmp/mobile-api-contract-1`. HAR import and optional OpenAPI cross-checking are described in [the contract guide](api-contracts.md). The exporter runs offline without Playwright or device tooling.
|
|
87
|
+
|
|
86
88
|
Give your coding agent the report, the current app repository, and an explicit target:
|
|
87
89
|
|
|
88
90
|
```text
|
package/docs/wiki.md
CHANGED
|
@@ -777,3 +777,8 @@ Connect your coding assistant using the [Figma MCP guide](figma-mcp.md). Use `/f
|
|
|
777
777
|
## Website API discovery and mobile handoff
|
|
778
778
|
|
|
779
779
|
Starting in v1.0.39, `npx mobile-ai-agents@latest network capture --url URL --out NEW_DIRECTORY` records bounded website flows with optional Playwright. The `/web-network-to-mobile` skill is installed across platform bundles and maps browser request/response evidence to the existing mobile stack. See [the setup and owned demo](website-api-discovery.md). Captures are observed shapes with temporal attribution; mobile implementation and actual network/UI verification remain separate.
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
### Versioned API handoff contracts
|
|
783
|
+
|
|
784
|
+
Starting in v1.0.40, `npx mobile-ai-agents@latest network contract --capture capture.json --out NEW_DIRECTORY` exports traceable mobile API contracts. Use `--har` for HAR imports and optional `--openapi` for local OpenAPI JSON cross-checking. Exports separate observed/documented/inferred evidence and stay needs-review with mobile verification unverified. See [API contracts](api-contracts.md).
|
package/package.json
CHANGED
|
@@ -27,6 +27,8 @@ This repository includes `node cli/index.js network capture --url URL --out NEW_
|
|
|
27
27
|
|
|
28
28
|
## Skill Prompt
|
|
29
29
|
|
|
30
|
+
For versioned artifacts, use `npx mobile-ai-agents@latest network contract --capture capture.json --out NEW_DIRECTORY`, or `--har session.har`, with optional `--openapi openapi.json`. Read [the contract guide](../../docs/api-contracts.md) for provenance, supported cross-checks and capture gaps. This exporter stays `needs-review` and does not establish a supported mobile contract or app parity. It requires mobile-ai-agents 1.0.40 or newer.
|
|
31
|
+
|
|
30
32
|
```text
|
|
31
33
|
You trace the requested website flow into an evidence-backed mobile API contract.
|
|
32
34
|
Read repository instructions, detect the existing target and networking architecture,
|