@aarwitz/tapp 0.15.0

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.
Files changed (77) hide show
  1. package/AGENTS.md +123 -0
  2. package/Harness/OCQAHarness/AppDelegate.swift +21 -0
  3. package/Harness/OCQAHarness/Info.plist +26 -0
  4. package/Harness/OCQAHarness.xcodeproj/project.pbxproj +199 -0
  5. package/Harness/OCQAHarness.xcodeproj/xcshareddata/xcschemes/OCQAHarnessUITests.xcscheme +22 -0
  6. package/Harness/OCQAHarnessUITests/ExplorerTests.swift +4526 -0
  7. package/Harness/OCQAHarnessUITests/Info.plist +22 -0
  8. package/Harness/generate-harness-xcodeproj.rb +254 -0
  9. package/LICENSE +21 -0
  10. package/README.md +374 -0
  11. package/bin/tapp.js +1382 -0
  12. package/browser/app.css +227 -0
  13. package/browser/app.js +675 -0
  14. package/browser/index.html +195 -0
  15. package/browser/product-contract.js +25 -0
  16. package/browser/view-model.js +16 -0
  17. package/docs/BROWSER-PRODUCT.md +72 -0
  18. package/docs/PRODUCT-ENGINE.md +102 -0
  19. package/docs/application-model.md +276 -0
  20. package/docs/scenarios.md +95 -0
  21. package/mcp-server/src/android-driver.js +287 -0
  22. package/mcp-server/src/android-explorer.js +197 -0
  23. package/mcp-server/src/android-flow.js +89 -0
  24. package/mcp-server/src/application-model.js +1597 -0
  25. package/mcp-server/src/browser-product.js +659 -0
  26. package/mcp-server/src/browser-workspaces.js +234 -0
  27. package/mcp-server/src/ci-report.js +557 -0
  28. package/mcp-server/src/ci-setup.js +359 -0
  29. package/mcp-server/src/contract-authoring.js +10 -0
  30. package/mcp-server/src/enrich.js +57 -0
  31. package/mcp-server/src/flow-runtime.js +127 -0
  32. package/mcp-server/src/html-report.js +124 -0
  33. package/mcp-server/src/index.js +3775 -0
  34. package/mcp-server/src/maintenance-proposal.js +178 -0
  35. package/mcp-server/src/managed-operation.js +61 -0
  36. package/mcp-server/src/pr-selection.js +841 -0
  37. package/mcp-server/src/product-execution.js +155 -0
  38. package/mcp-server/src/product-operations.js +526 -0
  39. package/mcp-server/src/project-config.js +101 -0
  40. package/mcp-server/src/release-contract.d.ts +81 -0
  41. package/mcp-server/src/release-contract.js +226 -0
  42. package/mcp-server/src/report.js +363 -0
  43. package/mcp-server/src/scenario-runtime.js +139 -0
  44. package/mcp-server/src/static-server.js +44 -0
  45. package/mcp-server/src/task-runtime.js +266 -0
  46. package/mcp-server/src/ui-map.js +661 -0
  47. package/mcp-server/src/web-explorer.js +493 -0
  48. package/mcp-server/src/web-flow.js +238 -0
  49. package/package.json +82 -0
  50. package/scripts/android-corpus-e2e.sh +30 -0
  51. package/scripts/ci-gate.sh +323 -0
  52. package/scripts/cleanup-xcode.sh +157 -0
  53. package/scripts/compile-contract.js +27 -0
  54. package/scripts/compile-flow.js +18 -0
  55. package/scripts/corpus-apps.txt +9 -0
  56. package/scripts/corpus-sweep.sh +121 -0
  57. package/scripts/coverage-eval.sh +92 -0
  58. package/scripts/coverage_eval_parse.py +95 -0
  59. package/scripts/deploy-and-build.sh +99 -0
  60. package/scripts/flow-platform.js +18 -0
  61. package/scripts/flow_ai_judge.py +102 -0
  62. package/scripts/flow_lib.py +154 -0
  63. package/scripts/mutation-recall-desktop.sh +186 -0
  64. package/scripts/mutation-recall.sh +121 -0
  65. package/scripts/mutation_lib.py +128 -0
  66. package/scripts/mutation_operators.py +144 -0
  67. package/scripts/platform-gate.js +186 -0
  68. package/scripts/pr-plan.js +68 -0
  69. package/scripts/quick-capture.sh +419 -0
  70. package/scripts/run-android-flow.js +27 -0
  71. package/scripts/run-flow.sh +90 -0
  72. package/scripts/run-web-flow.js +28 -0
  73. package/scripts/run-web-scenario.js +23 -0
  74. package/scripts/validation-matrix.sh +146 -0
  75. package/scripts/vision-fp-eval.sh +206 -0
  76. package/scripts/vision_escalation_responder.py +147 -0
  77. package/scripts/vision_fp_probe.py +221 -0
@@ -0,0 +1,234 @@
1
+ // Repository-source boundary for Tapp's customer browser. The browser may name a
2
+ // GitHub repository or upload repository files, but it never chooses a server
3
+ // filesystem path. Every imported repository receives an isolated workspace.
4
+ import crypto from "node:crypto";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { execFile } from "node:child_process";
9
+
10
+ function boundedLimit(name, fallback, minimum) {
11
+ const value = Number(process.env[name]);
12
+ return Number.isSafeInteger(value) && value >= minimum && value <= fallback ? value : fallback;
13
+ }
14
+
15
+ const MAX_FILES = boundedLimit("TAPP_MAX_UPLOAD_FILES", 25_000, 1);
16
+ const MAX_FILE_BYTES = boundedLimit("TAPP_MAX_UPLOAD_FILE_BYTES", 32 * 1024 * 1024, 1024);
17
+ const MAX_REPOSITORY_BYTES = boundedLimit("TAPP_MAX_UPLOAD_REPOSITORY_BYTES", 768 * 1024 * 1024, 1024);
18
+ const fileLimitLabel = `${Math.ceil(MAX_FILE_BYTES / (1024 * 1024))} MiB`;
19
+ const repositoryLimitLabel = `${Math.ceil(MAX_REPOSITORY_BYTES / (1024 * 1024))} MiB`;
20
+ const BLOCKED_SEGMENTS = new Set([".git", ".gradle", ".next", ".DS_Store", "Carthage", "DerivedData", "Pods", "build", "dist", "node_modules", "vendor"]);
21
+
22
+ function safeName(value, fallback = "repository") {
23
+ const result = String(value || "").trim().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
24
+ return result || fallback;
25
+ }
26
+
27
+ function publicRepository(record) {
28
+ if (!record) return null;
29
+ return {
30
+ id: record.id,
31
+ name: record.name,
32
+ source: record.source,
33
+ writable: record.writable,
34
+ ephemeral: record.ephemeral,
35
+ status: record.status,
36
+ fileCount: record.fileCount || 0,
37
+ byteCount: record.byteCount || 0,
38
+ connectedAt: record.connectedAt || null,
39
+ root: record.root,
40
+ };
41
+ }
42
+
43
+ function safeRelativeFile(value) {
44
+ const normalized = String(value || "").replaceAll("\\", "/").replace(/^\.\//, "");
45
+ if (!normalized || normalized.startsWith("/") || normalized.includes("\0")) throw new Error("Repository file path is required");
46
+ const parts = normalized.split("/");
47
+ if (parts.some((part) => !part || part === "." || part === "..")) throw new Error("Repository file path contains an unsafe segment");
48
+ if (parts.some((part) => BLOCKED_SEGMENTS.has(part))) throw new Error(`Repository upload excludes generated or dependency directory '${parts.find((part) => BLOCKED_SEGMENTS.has(part))}'`);
49
+ return parts.join("/");
50
+ }
51
+
52
+ function runFile(command, args, { cwd, timeout = 120_000 } = {}) {
53
+ return new Promise((resolve, reject) => {
54
+ execFile(command, args, { cwd, timeout, maxBuffer: 8 * 1024 * 1024, env: process.env }, (error, stdout, stderr) => {
55
+ if (error) {
56
+ const detail = String(stderr || stdout || error.message || "command failed").trim().slice(-2000);
57
+ reject(new Error(detail));
58
+ return;
59
+ }
60
+ resolve({ stdout: String(stdout || ""), stderr: String(stderr || "") });
61
+ });
62
+ });
63
+ }
64
+
65
+ export function createLocalGithubProvider({ run = runFile } = {}) {
66
+ return {
67
+ async list() {
68
+ try {
69
+ await run("gh", ["auth", "status"], { timeout: 30_000 });
70
+ // /user/repos covers owned, collaborator, and organization-member
71
+ // repositories. `gh repo list` without an owner silently omits important
72
+ // organization choices, which is exactly the ambiguity this picker must
73
+ // remove. gh's built-in --jq emits one bounded JSON object per line.
74
+ const result = await run("gh", [
75
+ "api", "--paginate", "user/repos?affiliation=owner,collaborator,organization_member&per_page=100&sort=updated",
76
+ "--jq", ".[] | {nameWithOwner:.full_name,name:.name,url:.html_url,defaultBranch:.default_branch,private:.private,permissions:.permissions}",
77
+ ], { timeout: 90_000 });
78
+ const repositories = result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map((line) => JSON.parse(line));
79
+ return repositories.map((repository) => ({
80
+ nameWithOwner: repository.nameWithOwner,
81
+ name: repository.name,
82
+ url: repository.url,
83
+ defaultBranch: repository.defaultBranch || "",
84
+ private: repository.private === true,
85
+ permission: repository.permissions?.admin ? "ADMIN" : repository.permissions?.maintain ? "MAINTAIN" : repository.permissions?.push ? "WRITE" : "READ",
86
+ })).sort((left, right) => left.nameWithOwner.localeCompare(right.nameWithOwner));
87
+ } catch (error) {
88
+ const wrapped = new Error(`GitHub connection needs an authenticated GitHub CLI session. Run 'gh auth login' locally, then retry. ${error.message || error}`);
89
+ wrapped.code = "github-auth-required";
90
+ throw wrapped;
91
+ }
92
+ },
93
+ async clone(nameWithOwner, destination) {
94
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(nameWithOwner || ""))) throw new Error("Select a repository returned by GitHub");
95
+ await run("gh", ["repo", "clone", nameWithOwner, destination, "--", "--depth=1"], { timeout: 10 * 60_000 });
96
+ return destination;
97
+ },
98
+ };
99
+ }
100
+
101
+ export class BrowserWorkspaceRegistry {
102
+ constructor({ initialProjectDir, workspaceRoot, githubProvider = createLocalGithubProvider() } = {}) {
103
+ this.ownsRoot = !workspaceRoot;
104
+ this.workspaceRoot = workspaceRoot
105
+ ? fs.realpathSync(path.resolve(workspaceRoot))
106
+ : fs.mkdtempSync(path.join(os.tmpdir(), "tapp-browser-workspaces-"));
107
+ this.githubProvider = githubProvider;
108
+ this.repositories = new Map();
109
+ this.uploads = new Map();
110
+ this.currentId = null;
111
+ if (initialProjectDir) this.addExisting(initialProjectDir);
112
+ }
113
+
114
+ addExisting(projectDir) {
115
+ const root = fs.realpathSync(path.resolve(projectDir));
116
+ if (!fs.statSync(root).isDirectory()) throw new Error(`Repository directory not found: ${root}`);
117
+ const id = `repo_${crypto.randomBytes(8).toString("hex")}`;
118
+ const record = {
119
+ id, root, name: path.basename(root), status: "ready", writable: true, ephemeral: false,
120
+ source: { kind: "local-checkout", label: root }, connectedAt: new Date().toISOString(),
121
+ };
122
+ this.repositories.set(id, record);
123
+ this.currentId = id;
124
+ return publicRepository(record);
125
+ }
126
+
127
+ list() { return [...this.repositories.values()].map(publicRepository); }
128
+ current() { return publicRepository(this.repositories.get(this.currentId)); }
129
+ currentRoot() { return this.repositories.get(this.currentId)?.root || null; }
130
+
131
+ select(id) {
132
+ const record = this.repositories.get(String(id || ""));
133
+ if (!record || record.status !== "ready") throw new Error("Repository workspace not found");
134
+ this.currentId = record.id;
135
+ return publicRepository(record);
136
+ }
137
+
138
+ createUpload({ name = "repository", expectedFiles = 0, expectedBytes = 0 } = {}) {
139
+ if (Number(expectedFiles) > MAX_FILES) throw new Error(`Repository contains more than ${MAX_FILES.toLocaleString()} uploadable files`);
140
+ if (Number(expectedBytes) > MAX_REPOSITORY_BYTES) throw new Error(`Repository upload exceeds the ${repositoryLimitLabel} workspace limit`);
141
+ const id = `upload_${crypto.randomBytes(10).toString("hex")}`;
142
+ const root = path.join(this.workspaceRoot, id, "repository");
143
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
144
+ const record = { id, root, name: safeName(name), status: "uploading", fileCount: 0, byteCount: 0, expectedFiles: Number(expectedFiles) || 0, expectedBytes: Number(expectedBytes) || 0 };
145
+ this.uploads.set(id, record);
146
+ return { id, limits: { maxFiles: MAX_FILES, maxFileBytes: MAX_FILE_BYTES, maxRepositoryBytes: MAX_REPOSITORY_BYTES } };
147
+ }
148
+
149
+ async writeUploadFile(id, relativePath, stream, declaredLength = 0) {
150
+ const upload = this.uploads.get(String(id || ""));
151
+ if (!upload || upload.status !== "uploading") throw new Error("Repository upload is not active");
152
+ const relative = safeRelativeFile(relativePath);
153
+ const length = Number(declaredLength) || 0;
154
+ if (length > MAX_FILE_BYTES) throw new Error(`File '${relative}' exceeds the ${fileLimitLabel} upload limit`);
155
+ if (upload.fileCount + 1 > MAX_FILES) throw new Error(`Repository contains more than ${MAX_FILES.toLocaleString()} files`);
156
+ if (upload.byteCount + length > MAX_REPOSITORY_BYTES) throw new Error(`Repository upload exceeds the ${repositoryLimitLabel} workspace limit`);
157
+ const destination = path.resolve(upload.root, relative);
158
+ if (!destination.startsWith(path.resolve(upload.root) + path.sep)) throw new Error("Repository file escapes its isolated workspace");
159
+ fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
160
+ const temporary = `${destination}.tapp-upload-${crypto.randomBytes(4).toString("hex")}`;
161
+ let received = 0;
162
+ await new Promise((resolve, reject) => {
163
+ const output = fs.createWriteStream(temporary, { flags: "wx", mode: 0o600 });
164
+ const fail = (error) => { stream.destroy(); output.destroy(); fs.rmSync(temporary, { force: true }); reject(error); };
165
+ stream.on("data", (chunk) => {
166
+ received += chunk.length;
167
+ if (received > MAX_FILE_BYTES || upload.byteCount + received > MAX_REPOSITORY_BYTES) fail(new Error(`File '${relative}' exceeds the upload limit`));
168
+ });
169
+ stream.on("error", fail);
170
+ output.on("error", fail);
171
+ output.on("finish", resolve);
172
+ stream.pipe(output);
173
+ });
174
+ fs.renameSync(temporary, destination);
175
+ upload.fileCount += 1;
176
+ upload.byteCount += received;
177
+ return { relativePath: relative, bytes: received, fileCount: upload.fileCount, byteCount: upload.byteCount };
178
+ }
179
+
180
+ finishUpload(id) {
181
+ const upload = this.uploads.get(String(id || ""));
182
+ if (!upload || upload.status !== "uploading") throw new Error("Repository upload is not active");
183
+ if (!upload.fileCount) throw new Error("The selected folder contained no uploadable repository files");
184
+ upload.status = "ready";
185
+ upload.source = { kind: "local-folder-upload", label: upload.name, note: "isolated working copy; export or apply the reviewed patch to update the original folder" };
186
+ upload.writable = true;
187
+ upload.ephemeral = true;
188
+ upload.connectedAt = new Date().toISOString();
189
+ this.uploads.delete(upload.id);
190
+ this.repositories.set(upload.id, upload);
191
+ this.currentId = upload.id;
192
+ return publicRepository(upload);
193
+ }
194
+
195
+ abortUpload(id) {
196
+ const upload = this.uploads.get(String(id || ""));
197
+ if (!upload) return false;
198
+ this.uploads.delete(upload.id);
199
+ fs.rmSync(path.dirname(upload.root), { recursive: true, force: true });
200
+ return true;
201
+ }
202
+
203
+ async listGithub() { return this.githubProvider.list(); }
204
+
205
+ async cloneGithub(nameWithOwner, onProgress = () => {}) {
206
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(nameWithOwner || ""))) throw new Error("Select a valid owner/repository");
207
+ const id = `github_${crypto.randomBytes(10).toString("hex")}`;
208
+ const destination = path.join(this.workspaceRoot, id, "repository");
209
+ fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
210
+ onProgress({ phase: "repository", text: `Cloning ${nameWithOwner} into an isolated workspace` });
211
+ try {
212
+ await this.githubProvider.clone(nameWithOwner, destination);
213
+ const root = fs.realpathSync(destination);
214
+ const record = {
215
+ id, root, name: nameWithOwner.split("/").at(-1), status: "ready", writable: true, ephemeral: true,
216
+ source: { kind: "github", nameWithOwner, label: nameWithOwner, note: "isolated checkout; Tapp never pushes without explicit authorization" },
217
+ connectedAt: new Date().toISOString(),
218
+ };
219
+ this.repositories.set(id, record);
220
+ this.currentId = id;
221
+ onProgress({ phase: "repository", text: `Connected ${nameWithOwner}` });
222
+ return publicRepository(record);
223
+ } catch (error) {
224
+ fs.rmSync(path.dirname(destination), { recursive: true, force: true });
225
+ throw error;
226
+ }
227
+ }
228
+
229
+ close() {
230
+ if (this.ownsRoot) fs.rmSync(this.workspaceRoot, { recursive: true, force: true });
231
+ }
232
+ }
233
+
234
+ export const browserWorkspaceLimits = Object.freeze({ MAX_FILES, MAX_FILE_BYTES, MAX_REPOSITORY_BYTES });