@impeccable/reviews 0.1.1 → 0.1.2
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 +25 -7
- package/dist/agent.js +2 -2
- package/dist/bin.js +38 -0
- package/dist/cloudflare-access.js +157 -49
- package/dist/cloudflare.js +93 -51
- package/dist/fence.js +106 -38
- package/dist/host-questions.js +4 -3
- package/dist/hosted.js +29 -8
- package/dist/log.js +6 -1
- package/dist/login.js +8 -6
- package/dist/main.js +76 -23
- package/dist/netlify-password.js +2 -1
- package/dist/netlify.js +4 -1
- package/dist/playbook/cases/cloudflare-previews.md +2 -2
- package/dist/playbook/cases/netlify-setup.md +1 -1
- package/dist/playbook/cases/preview-protection.md +3 -3
- package/dist/playbook/github-previews.js +101 -0
- package/dist/playbook/netlify-pattern.js +24 -0
- package/dist/playbook/netlify-previews.js +144 -0
- package/dist/playbook/probe-preview.js +67 -0
- package/dist/playbook/protection-secrets.js +15 -0
- package/dist/playbook/public-preview-request.js +96 -0
- package/dist/playbook/publication-wait.js +46 -0
- package/dist/playbook/vercel-protection.js +121 -0
- package/dist/playbook.js +1 -0
- package/dist/preflight.js +52 -12
- package/dist/report.js +68 -0
- package/dist/sandbox.js +280 -0
- package/dist/tools.js +54 -34
- package/dist/ui/look.js +13 -5
- package/dist/ui/run-state.js +9 -9
- package/dist/ui/screens.js +15 -16
- package/package.json +2 -1
package/dist/report.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
/** How long the command waits for its reports at the end: never long enough for anyone to notice. */
|
|
4
|
+
const SEND_MS = 2000;
|
|
5
|
+
/** Off with --no-telemetry or DO_NOT_TRACK=1: nothing is sent, not even the launch. */
|
|
6
|
+
export function reportingOff(env, flag) {
|
|
7
|
+
return flag || /^(1|true|yes)$/i.test(env.DO_NOT_TRACK?.trim() ?? '');
|
|
8
|
+
}
|
|
9
|
+
function version() {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return 'unknown';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The command's reporter. Before sign-in it holds its steps, since only a
|
|
19
|
+
* token names the person they belong to; signing in sends them with it, at the
|
|
20
|
+
* time each happened. A command that ends without signing in sends them with
|
|
21
|
+
* no token, under its launch id alone, and the Worker keeps them unnamed.
|
|
22
|
+
* Nothing it sends can fail the command or hold it past two seconds.
|
|
23
|
+
*/
|
|
24
|
+
export function createReporter(opts) {
|
|
25
|
+
const send = opts.fetchImpl ?? fetch;
|
|
26
|
+
const launchId = opts.launchId ?? randomUUID();
|
|
27
|
+
const about = { launch_id: launchId, version: version(), node_major: Number(process.versions.node.split('.')[0]), os: process.platform };
|
|
28
|
+
let held = [];
|
|
29
|
+
let auth;
|
|
30
|
+
const sending = new Set();
|
|
31
|
+
const post = (events, token, url = `${opts.host}/app/cli/events`) => {
|
|
32
|
+
if (!events.length)
|
|
33
|
+
return;
|
|
34
|
+
const sent = send(url, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
37
|
+
body: JSON.stringify({ ...about, events }),
|
|
38
|
+
signal: AbortSignal.timeout(SEND_MS),
|
|
39
|
+
}).catch(() => undefined);
|
|
40
|
+
sending.add(sent);
|
|
41
|
+
void sent.finally(() => sending.delete(sent));
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
step(step) {
|
|
45
|
+
if (opts.off)
|
|
46
|
+
return;
|
|
47
|
+
const stamped = { ...step, at: new Date().toISOString() };
|
|
48
|
+
if (auth)
|
|
49
|
+
post([stamped], auth.token, auth.url);
|
|
50
|
+
else
|
|
51
|
+
held.push(stamped);
|
|
52
|
+
},
|
|
53
|
+
signedIn(token, cliApi) {
|
|
54
|
+
if (opts.off)
|
|
55
|
+
return;
|
|
56
|
+
auth = { token, url: `${cliApi.replace(/\/$/, '')}/events` };
|
|
57
|
+
post(held, token, auth.url);
|
|
58
|
+
held = [];
|
|
59
|
+
},
|
|
60
|
+
async done() {
|
|
61
|
+
if (opts.off)
|
|
62
|
+
return;
|
|
63
|
+
post(held);
|
|
64
|
+
held = [];
|
|
65
|
+
await Promise.race([Promise.allSettled([...sending]), new Promise((resolve) => setTimeout(resolve, SEND_MS).unref())]);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { homedir, tmpdir } from 'node:os';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
7
|
+
import { SandboxManager } from '@anthropic-ai/sandbox-runtime';
|
|
8
|
+
import { createLocalBashOperations } from '@earendil-works/pi-coding-agent';
|
|
9
|
+
import { UserError } from './log.js';
|
|
10
|
+
/** Its git folders, as git names them; readCheckout has already refused a checkout whose own .git holds a commondir. */
|
|
11
|
+
export function gitDirsOf(root) {
|
|
12
|
+
const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
13
|
+
return { root: realpathSync(root), gitDir: realpathSync(git('rev-parse', '--absolute-git-dir')), commonDir: realpathSync(resolve(root, git('rev-parse', '--git-common-dir'))) };
|
|
14
|
+
}
|
|
15
|
+
let pinned;
|
|
16
|
+
let own;
|
|
17
|
+
/**
|
|
18
|
+
* A folder of the command's own, outside the checkout and everything a sandboxed command writes. What runs from here
|
|
19
|
+
* as the person and fetches a package (npx for cf, a host CLI's install) reads no .npmrc and no node_modules of the
|
|
20
|
+
* repository's choosing.
|
|
21
|
+
*/
|
|
22
|
+
export function ownFolder() {
|
|
23
|
+
if (!own) {
|
|
24
|
+
const folder = (own = mkdtempSync(join(tmpdir(), 'impeccable-reviews-own-')));
|
|
25
|
+
process.on('exit', () => rmSync(folder, { recursive: true, force: true }));
|
|
26
|
+
}
|
|
27
|
+
return own;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The environment of every git that runs outside the sandbox, the command's own and the person's commands (gh and the
|
|
31
|
+
* host CLIs run git too): no hooks and no fsmonitor, whose programs a sandboxed command could have written, and, once
|
|
32
|
+
* the sandbox started, this checkout's git folders named rather than found, so a .git or a commondir a sandboxed
|
|
33
|
+
* command made is never what git reads.
|
|
34
|
+
*/
|
|
35
|
+
export function gitEnvironment(env = process.env) {
|
|
36
|
+
const settings = [env.GIT_CONFIG_PARAMETERS, "'core.hooksPath=/dev/null' 'core.fsmonitor=false'"].filter(Boolean).join(' ');
|
|
37
|
+
return { ...env, GIT_CONFIG_PARAMETERS: settings, ...(pinned ? { GIT_DIR: pinned.gitDir, GIT_COMMON_DIR: pinned.commonDir, GIT_WORK_TREE: pinned.root } : {}) };
|
|
38
|
+
}
|
|
39
|
+
/** Credential folders and files, from the home folder, that no command in the sandbox reads: SSH's, GPG's and the cloud CLIs' keys, and git's, gh's and Docker's sign-ins. */
|
|
40
|
+
const CREDENTIALS = ['.ssh', '.gnupg', '.aws', '.azure', '.config/gcloud', '.kube', '.docker', '.netrc', '.git-credentials', '.config/gh'];
|
|
41
|
+
/** The package managers' caches and stores, which an install writes. */
|
|
42
|
+
const CACHES = ['.npm', '.pnpm-store', '.local/share/pnpm', '.cache/pnpm', '.cache/yarn', '.yarn/berry', '.bun/install/cache', '.cache/node/corepack', '.cache/prisma'];
|
|
43
|
+
/** macOS keeps the keychains, and some of those caches, under Library. */
|
|
44
|
+
const MACOS = { credentials: ['Library/Keychains'], caches: ['Library/pnpm', 'Library/Caches/pnpm', 'Library/Caches/Yarn'] };
|
|
45
|
+
/**
|
|
46
|
+
* What a command in the sandbox reads and writes: it reads what the person can but the credential folders, and writes
|
|
47
|
+
* in the checkout, its git folders (a worktree's are outside it), the run's scratch folder and the package caches, but
|
|
48
|
+
* never where git reads settings or programs from: config, hooks, info, a commondir, the submodules' and the other
|
|
49
|
+
* worktrees' git folders, and a .git below the checkout's top. Bubblewrap takes no pattern in a write rule, and puts a
|
|
50
|
+
* file in place of a denied path that is not there, which git would read as an empty commondir: on Linux only what is
|
|
51
|
+
* there is denied, and the git that runs outside is pinned to this checkout's folders instead.
|
|
52
|
+
*/
|
|
53
|
+
export function sandboxPolicy(o) {
|
|
54
|
+
const macos = o.platform === 'darwin';
|
|
55
|
+
const { root, gitDir, commonDir } = o.git;
|
|
56
|
+
const inHome = (paths) => paths.map((path) => join(o.home, path));
|
|
57
|
+
const denyWrite = [
|
|
58
|
+
...['config', 'config.worktree', 'hooks', 'info', 'modules'].map((name) => join(commonDir, name)),
|
|
59
|
+
...['commondir', 'config.worktree'].map((name) => join(gitDir, name)),
|
|
60
|
+
...o.worktrees.flatMap((name) => ['commondir', 'config.worktree'].map((file) => join(commonDir, 'worktrees', name, file))),
|
|
61
|
+
// A worktree's or a submodule's .git is a file that names its git folder.
|
|
62
|
+
...(gitDir === join(root, '.git') ? [] : [join(root, '.git')]),
|
|
63
|
+
...(macos ? [join(root, '*', '**', '.git')] : []),
|
|
64
|
+
// Inside npm's cache, which installs write: what npx installed, which the command's own tools run outside the sandbox.
|
|
65
|
+
join(o.home, '.npm', '_npx'),
|
|
66
|
+
];
|
|
67
|
+
const exists = o.exists ?? existsSync;
|
|
68
|
+
return {
|
|
69
|
+
denyRead: inHome([...CREDENTIALS, ...(macos ? MACOS.credentials : [])]),
|
|
70
|
+
allowWrite: [...new Set([root, gitDir, commonDir, o.scratch, ...inHome([...CACHES, ...(macos ? MACOS.caches : [])])])],
|
|
71
|
+
denyWrite: [...new Set(macos ? denyWrite : denyWrite.filter((path) => exists(path)))],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** GitHub and the hosts it serves files from, for a dependency on GitHub. */
|
|
75
|
+
const GITHUB = ['github.com', '*.github.com', '*.githubusercontent.com'];
|
|
76
|
+
/** The public registries a kind of manifest or lockfile installs from. */
|
|
77
|
+
const REGISTRIES = [
|
|
78
|
+
[/(?:^|\/)(?:package\.json|package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|bun\.lockb?)$/, ['registry.npmjs.org']],
|
|
79
|
+
[/(?:^|\/)(?:yarn\.lock|\.yarnrc\.yml)$/, ['registry.npmjs.org', 'registry.yarnpkg.com', 'repo.yarnpkg.com']],
|
|
80
|
+
[/(?:^|\/)(?:requirements[\w.-]*\.txt|pyproject\.toml|Pipfile|Pipfile\.lock|poetry\.lock|uv\.lock)$/, ['pypi.org', 'files.pythonhosted.org']],
|
|
81
|
+
];
|
|
82
|
+
/** What a dependency downloads while the app installs or builds: Google's fonts for next/font, Prisma's engines. */
|
|
83
|
+
const DOWNLOADS = {
|
|
84
|
+
next: ['fonts.googleapis.com', 'fonts.gstatic.com'],
|
|
85
|
+
prisma: ['binaries.prisma.sh'],
|
|
86
|
+
'@prisma/client': ['binaries.prisma.sh'],
|
|
87
|
+
};
|
|
88
|
+
/** A host's API, once the person said yes to setting that host up. */
|
|
89
|
+
const HOST_APIS = { vercel: ['vercel.com', '*.vercel.com'], netlify: ['netlify.com', '*.netlify.com'] };
|
|
90
|
+
/** The hosts of the registries an npm config names, in its registry=, @scope:registry= and //host/:_authToken= lines; never a value's secret. */
|
|
91
|
+
export function npmrcHosts(text) {
|
|
92
|
+
const hosts = [];
|
|
93
|
+
for (const line of text.split(/\r?\n/)) {
|
|
94
|
+
const registry = /^\s*(?:@[\w.-]+:)?registry\s*=\s*(\S+)/i.exec(line)?.[1];
|
|
95
|
+
const auth = /^\s*\/\/([^/\s:]+(?::\d+)?)\//.exec(line)?.[1];
|
|
96
|
+
if (auth)
|
|
97
|
+
hosts.push(auth.toLowerCase());
|
|
98
|
+
else if (registry && URL.canParse(registry))
|
|
99
|
+
hosts.push(new URL(registry).host);
|
|
100
|
+
}
|
|
101
|
+
return hosts;
|
|
102
|
+
}
|
|
103
|
+
/** The names of a package.json's dependencies of every kind; none when it cannot be read. */
|
|
104
|
+
function dependenciesOf(json) {
|
|
105
|
+
try {
|
|
106
|
+
const manifest = JSON.parse(json ?? '');
|
|
107
|
+
return ['dependencies', 'devDependencies', 'optionalDependencies'].flatMap((field) => Object.keys(manifest[field] ?? {}));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The hosts a command in the sandbox reaches: --host, GitHub, the public registries the checkout's manifests and
|
|
115
|
+
* lockfiles install from, what its dependencies download, the registries of the person's own npm config, and a host's
|
|
116
|
+
* API once they said yes to setting it up. A registry the checkout's own lockfile names is its text, not the person's:
|
|
117
|
+
* it is reached when it is a public one, or theirs.
|
|
118
|
+
*/
|
|
119
|
+
export function allowedDomains(s) {
|
|
120
|
+
const host = new URL(s.host);
|
|
121
|
+
const names = [host.port ? `${host.hostname}:${host.port}` : host.hostname, ...GITHUB];
|
|
122
|
+
for (const [kind, registries] of REGISTRIES)
|
|
123
|
+
if (s.files.some((file) => kind.test(file)))
|
|
124
|
+
names.push(...registries);
|
|
125
|
+
// The checkout's own manifests: node_modules holds other people's, and can hold thousands.
|
|
126
|
+
for (const manifest of s.files.filter((file) => /(?:^|\/)package\.json$/.test(file) && !/(?:^|\/)node_modules\//.test(file)))
|
|
127
|
+
names.push(...dependenciesOf(s.read(manifest)).flatMap((name) => DOWNLOADS[name] ?? []));
|
|
128
|
+
names.push(...npmrcHosts(s.npmrc ?? ''));
|
|
129
|
+
for (const agreed of s.hosts)
|
|
130
|
+
names.push(...(HOST_APIS[agreed] ?? []));
|
|
131
|
+
return [...new Set(names)];
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* What a command in the sandbox also gets: Node's fetch through the sandbox's proxy; no browser downloads, which land in
|
|
135
|
+
* caches it does not write, from hosts it does not reach; and no git hooks installed by husky, which writes the git config
|
|
136
|
+
* the sandbox keeps.
|
|
137
|
+
*/
|
|
138
|
+
const INSIDE = { NODE_USE_ENV_PROXY: '1', CYPRESS_INSTALL_BINARY: '0', PUPPETEER_SKIP_DOWNLOAD: '1', PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1', HUSKY: '0' };
|
|
139
|
+
/** The sandbox's own command line, which the local preview check runs each command through, in a sandbox of its own. */
|
|
140
|
+
const SRT = createRequire(import.meta.url).resolve('@anthropic-ai/sandbox-runtime/dist/cli.js');
|
|
141
|
+
/** Runs `true` in the sandbox: undefined when it did, else the first line of why not. */
|
|
142
|
+
async function selfTest() {
|
|
143
|
+
const wrapped = await SandboxManager.wrapWithSandbox('true');
|
|
144
|
+
return new Promise((done) => {
|
|
145
|
+
let said = '';
|
|
146
|
+
const child = spawn(existsSync('/bin/bash') ? '/bin/bash' : 'bash', ['-c', wrapped], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
147
|
+
child.stderr.on('data', (chunk) => (said += chunk.toString()));
|
|
148
|
+
child.once('error', (err) => done(err.message));
|
|
149
|
+
child.once('close', (code) => done(code === 0 ? undefined : said.trim().split('\n')[0] || `it exited with ${code}`));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/** The files the checkout tracks and the new ones it does not ignore, by path from its top. */
|
|
153
|
+
function checkoutFiles(root) {
|
|
154
|
+
try {
|
|
155
|
+
return execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 256 * 1024 * 1024, env: gitEnvironment() }).split('\0').filter(Boolean);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const readText = (path) => {
|
|
162
|
+
try {
|
|
163
|
+
return readFileSync(path, 'utf8');
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* Starts the sandbox for this run, before anything runs: on macOS a sandbox that will not start stops the command,
|
|
171
|
+
* and elsewhere its commands run unsandboxed, which `warning` says once, on the first screen. From here on git
|
|
172
|
+
* outside the sandbox is pinned to this checkout's folders.
|
|
173
|
+
*/
|
|
174
|
+
export async function startSandbox(o) {
|
|
175
|
+
const platform = process.platform;
|
|
176
|
+
const home = o.home ?? homedir();
|
|
177
|
+
const git = gitDirsOf(o.root);
|
|
178
|
+
pinned = git;
|
|
179
|
+
// Bubblewrap denies only what is there, so the folder npx keeps its installs in is made before any command runs: a command in the sandbox never gets to make it its own.
|
|
180
|
+
try {
|
|
181
|
+
mkdirSync(join(home, '.npm', '_npx'), { recursive: true });
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// A home that takes no folder takes none from a sandboxed command either.
|
|
185
|
+
}
|
|
186
|
+
const worktrees = (() => {
|
|
187
|
+
try {
|
|
188
|
+
return readdirSync(join(git.commonDir, 'worktrees'));
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
})();
|
|
194
|
+
const policy = sandboxPolicy({ git, scratch: realpathSync(o.scratch), home, platform, worktrees });
|
|
195
|
+
const files = checkoutFiles(git.root);
|
|
196
|
+
const sources = { host: o.host, files, read: (path) => readText(join(git.root, path)), npmrc: readText(join(home, '.npmrc')) };
|
|
197
|
+
let domains = allowedDomains({ ...sources, hosts: o.hosts() });
|
|
198
|
+
const config = { network: { allowedDomains: domains, deniedDomains: [] }, filesystem: policy };
|
|
199
|
+
// Temp files land in the scratch folder: the sandbox's runtime names the folder its commands get as TMPDIR from this variable.
|
|
200
|
+
const temp = join(o.scratch, 'tmp');
|
|
201
|
+
mkdirSync(temp, { recursive: true });
|
|
202
|
+
process.env.CLAUDE_CODE_TMPDIR = temp;
|
|
203
|
+
// The local preview check's settings sit where no sandboxed command writes.
|
|
204
|
+
const settings = ownFolder();
|
|
205
|
+
let off;
|
|
206
|
+
if (platform !== 'darwin' && platform !== 'linux')
|
|
207
|
+
off = `no sandbox for ${platform}`;
|
|
208
|
+
else if (!SandboxManager.isSupportedPlatform())
|
|
209
|
+
off = 'no sandbox for this system';
|
|
210
|
+
else {
|
|
211
|
+
const missing = SandboxManager.checkDependencies().errors;
|
|
212
|
+
if (missing.length)
|
|
213
|
+
off = missing.join(', ');
|
|
214
|
+
}
|
|
215
|
+
if (!off) {
|
|
216
|
+
try {
|
|
217
|
+
// A host off the list is refused by the sandbox's proxy; the debug log names it, which says what a failed install or build wanted.
|
|
218
|
+
await SandboxManager.initialize(config, async ({ host, port }) => {
|
|
219
|
+
o.log.write('sandbox refused a host', `${host}:${port}`);
|
|
220
|
+
return false;
|
|
221
|
+
});
|
|
222
|
+
off = await selfTest();
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
off = (err instanceof Error ? err.message : String(err)).split('\n')[0];
|
|
226
|
+
}
|
|
227
|
+
if (off)
|
|
228
|
+
await SandboxManager.reset().catch(() => { });
|
|
229
|
+
}
|
|
230
|
+
if (off && platform === 'darwin') {
|
|
231
|
+
throw new UserError(`Impeccable runs the setup's commands in macOS's sandbox, and it did not start here (${off}). Run impeccable-reviews in a terminal of your own, outside any other sandbox.`, { event: 'setup_command_check_failed', outcome: 'sandbox_failed' });
|
|
232
|
+
}
|
|
233
|
+
o.log.write(off ? `sandbox off: ${off}` : 'sandbox on', { domains, ...policy });
|
|
234
|
+
const local = createLocalBashOperations();
|
|
235
|
+
let running = 0;
|
|
236
|
+
const shell = {
|
|
237
|
+
exec: async (command, cwd, options) => {
|
|
238
|
+
const place = o.place(command);
|
|
239
|
+
if (place === 'install')
|
|
240
|
+
return local.exec(command, ownFolder(), options);
|
|
241
|
+
if (place === 'person')
|
|
242
|
+
return local.exec(command, cwd, { ...options, env: gitEnvironment(options.env) });
|
|
243
|
+
if (off)
|
|
244
|
+
return local.exec(command, cwd, options);
|
|
245
|
+
// A host's API joins the list once the person said yes to setting that host up.
|
|
246
|
+
const next = allowedDomains({ ...sources, hosts: o.hosts() });
|
|
247
|
+
if (next.join() !== domains.join()) {
|
|
248
|
+
domains = next;
|
|
249
|
+
SandboxManager.updateConfig({ ...config, network: { ...config.network, allowedDomains: domains } });
|
|
250
|
+
o.log.write('sandbox hosts', domains);
|
|
251
|
+
}
|
|
252
|
+
const wrapped = await SandboxManager.wrapWithSandbox(command, undefined, undefined, options.signal);
|
|
253
|
+
running++;
|
|
254
|
+
try {
|
|
255
|
+
return await local.exec(wrapped, cwd, { ...options, env: { ...(options.env ?? process.env), ...INSIDE } });
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
// On Linux this takes away the files bubblewrap put in place of absent denied paths: only once no command still runs on them.
|
|
259
|
+
if (--running === 0)
|
|
260
|
+
SandboxManager.cleanupAfterCommand();
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
return {
|
|
265
|
+
warning: off && `${off[0].toUpperCase()}${off.slice(1)}, so the setup's commands run without a sandbox, with all your access.`,
|
|
266
|
+
shell,
|
|
267
|
+
preview(folder) {
|
|
268
|
+
if (off || platform === 'linux')
|
|
269
|
+
return null;
|
|
270
|
+
const file = join(settings, `preview-${randomBytes(6).toString('hex')}.json`);
|
|
271
|
+
const own = {
|
|
272
|
+
network: { allowedDomains: domains, deniedDomains: [], allowLocalBinding: true },
|
|
273
|
+
filesystem: { denyRead: policy.denyRead, allowWrite: [realpathSync(folder)], denyWrite: [] },
|
|
274
|
+
};
|
|
275
|
+
writeFileSync(file, JSON.stringify(own), { mode: 0o600 });
|
|
276
|
+
// The command's TMPDIR comes from CLAUDE_CODE_TMPDIR; the sandbox's own sockets stay in this process's temp folder, whose path fits a socket's.
|
|
277
|
+
return (command, env) => ({ file: process.execPath, args: [SRT, '--settings', file, '-c', command], env: { ...env, ...INSIDE, CLAUDE_CODE_TMPDIR: env.TMPDIR ?? join(folder, 'tmp'), TMPDIR: tmpdir() } });
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
package/dist/tools.js
CHANGED
|
@@ -7,12 +7,12 @@ import { request } from 'node:http';
|
|
|
7
7
|
import { createServer } from 'node:net';
|
|
8
8
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
9
9
|
import { defineTool } from '@earendil-works/pi-coding-agent';
|
|
10
|
-
import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, classifyPreviewCommand, hostSetupAgreed, isSecretFile, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment } from './fence.js';
|
|
10
|
+
import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, classifyPreviewCommand, endHostSetup, hostSetupAgreed, isSecretFile, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment } from './fence.js';
|
|
11
11
|
import { requestJson } from './http.js';
|
|
12
12
|
import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps } from './focus.js';
|
|
13
13
|
import { HOST_QUESTIONS, HOST_SCREENS } from './host-questions.js';
|
|
14
14
|
import { netlifySiteOf, previewHostName, provePreview, readHostedPreviews } from './hosted.js';
|
|
15
|
-
import { pagesProject, readCloudflarePreviews } from './cloudflare.js';
|
|
15
|
+
import { cfRunner, pagesProject, readCloudflarePreviews } from './cloudflare.js';
|
|
16
16
|
import { passCloudflareAccess } from './cloudflare-access.js';
|
|
17
17
|
import { commandRunner, connectNetlifySite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
|
|
18
18
|
import { basicCredentials, PASSWORD_HEADER, passNetlifyLock } from './netlify-password.js';
|
|
@@ -20,6 +20,7 @@ import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, ve
|
|
|
20
20
|
import { BYPASS_HEADER, passVercelLogin, readLink } from './vercel-bypass.js';
|
|
21
21
|
import { fitsTask, stagesOf } from './stages.js';
|
|
22
22
|
import { redact } from './log.js';
|
|
23
|
+
import { gitEnvironment } from './sandbox.js';
|
|
23
24
|
import { openBrowser } from './login.js';
|
|
24
25
|
import { TASK_STATUSES } from './sync.js';
|
|
25
26
|
import { askPerson, reportStatus, reportTasks, reportWait } from './ui/bridge.js';
|
|
@@ -409,7 +410,7 @@ async function stopPreviewChild(child) {
|
|
|
409
410
|
catch { /* already stopped */ }
|
|
410
411
|
}
|
|
411
412
|
/** Local reproduction only: no credentials, external database, response body or process output leaves this helper. */
|
|
412
|
-
export async function verifyLocalPreview(input, root, signal) {
|
|
413
|
+
export async function verifyLocalPreview(input, root, signal, sandbox) {
|
|
413
414
|
const probes = [];
|
|
414
415
|
const fail = (stage, reason) => ({ verified: false, stage, reason, probes });
|
|
415
416
|
if (process.platform === 'win32')
|
|
@@ -506,8 +507,11 @@ export async function verifyLocalPreview(input, root, signal) {
|
|
|
506
507
|
scratch = await realpath(await mkdtemp(join('/tmp', 'impeccable-preview-')));
|
|
507
508
|
const snapshot = join(scratch, 'checkout'), home = join(scratch, 'home'), temp = join(scratch, 'tmp');
|
|
508
509
|
await Promise.all([snapshot, home, temp].map((p) => mkdir(p, { mode: 0o700 })));
|
|
510
|
+
// The app's own commands run in a sandbox of their own, writing only in this folder; the check's git and Docker run outside it.
|
|
511
|
+
const inSandbox = sandbox?.preview(scratch);
|
|
512
|
+
const confined = (command, processEnv) => inSandbox?.(command, processEnv) ?? { file: '/bin/sh', args: ['-c', command], env: processEnv };
|
|
509
513
|
env = { ...env, ...supplied, ...(serverInput ? { PORT: String(serverInput.port) } : {}), HOME: home, TMPDIR: temp, TMP: temp, TEMP: temp, XDG_CONFIG_HOME: join(home, '.config'), npm_config_cache: join(scratch, 'npm'), npm_config_userconfig: join(home, '.npmrc'), npm_config_globalconfig: join(scratch, 'global-npmrc'), PIP_CONFIG_FILE: '/dev/null', PIP_NO_INPUT: '1', PIP_DISABLE_PIP_VERSION_CHECK: '1', PIP_REQUIRE_VIRTUALENV: 'true', BUNDLE_PATH: join(scratch, 'bundle'), BUNDLE_APP_CONFIG: join(scratch, 'bundle-config') };
|
|
510
|
-
const listing = await run('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], root, true);
|
|
514
|
+
const listing = await run('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], root, true, 120_000, signal ?? null, gitEnvironment(env));
|
|
511
515
|
if (!listing.ok)
|
|
512
516
|
return fail(stage, 'Could not list checkout files.');
|
|
513
517
|
const files = [...new Set(listing.output.split('\0').filter(Boolean))];
|
|
@@ -547,7 +551,7 @@ export async function verifyLocalPreview(input, root, signal) {
|
|
|
547
551
|
await copyFile(source, target);
|
|
548
552
|
await chmod(target, info.mode & 0o777);
|
|
549
553
|
if (file.split('/').at(-1) === 'package.json') {
|
|
550
|
-
const baseline = await run('git', ['show', `HEAD:${file}`], root, true);
|
|
554
|
+
const baseline = await run('git', ['show', `HEAD:${file}`], root, true, 120_000, signal ?? null, gitEnvironment(env));
|
|
551
555
|
const scripts = (value) => Object.entries(JSON.parse(value).scripts ?? {}).sort(([a], [b]) => a.localeCompare(b));
|
|
552
556
|
scriptsChanged ||= !baseline.ok || JSON.stringify(scripts(baseline.output)) !== JSON.stringify(scripts(await readFile(source, 'utf8')));
|
|
553
557
|
}
|
|
@@ -629,8 +633,9 @@ export async function verifyLocalPreview(input, root, signal) {
|
|
|
629
633
|
}
|
|
630
634
|
if (!executable)
|
|
631
635
|
return fail(stage, 'The required Python runtime or virtual environment is unavailable.');
|
|
632
|
-
// Precreate even for explicit bootstrap steps so every command in a compound step uses it.
|
|
633
|
-
|
|
636
|
+
// Precreate even for explicit bootstrap steps so every command in a compound step uses it. `-m` imports from the folder, the checkout's code, so it runs in the sandbox too.
|
|
637
|
+
const venv = confined(`'${executable.replace(/'/g, `'\\''`)}' -m venv .venv`, env);
|
|
638
|
+
if (!(await run(venv.file, venv.args, directory, false, 120_000, signal ?? null, venv.env)).ok)
|
|
634
639
|
return fail(stage, 'The required Python runtime or virtual environment is unavailable.');
|
|
635
640
|
}
|
|
636
641
|
const environmentFor = (directory) => {
|
|
@@ -666,7 +671,8 @@ export async function verifyLocalPreview(input, root, signal) {
|
|
|
666
671
|
}
|
|
667
672
|
for (const [index, step] of input.steps.entries()) {
|
|
668
673
|
stage = step.phase;
|
|
669
|
-
|
|
674
|
+
const command = confined(step.command, environmentFor(directories[index]));
|
|
675
|
+
if (!(await run(command.file, command.args, directories[index], false, 120_000, signal ?? null, command.env)).ok)
|
|
670
676
|
return fail(stage, signal?.aborted ? 'Verification was cancelled.' : 'The local step failed or exceeded two minutes; its output was not retained.');
|
|
671
677
|
}
|
|
672
678
|
if (staticInput) {
|
|
@@ -682,7 +688,8 @@ export async function verifyLocalPreview(input, root, signal) {
|
|
|
682
688
|
const free = await new Promise((done) => { const check = createServer(); check.once('error', () => done(false)); check.listen(serverInput.port, '127.0.0.1', () => check.close(() => done(true))); });
|
|
683
689
|
if (!free)
|
|
684
690
|
return fail(stage, 'The preview port is already in use; an existing service was not probed.');
|
|
685
|
-
|
|
691
|
+
const start = confined(serverInput.start.command, environmentFor(directories.at(-1)));
|
|
692
|
+
server = spawn(start.file, start.args, { cwd: directories.at(-1), env: start.env, detached: true, stdio: 'ignore' });
|
|
686
693
|
let exited = false;
|
|
687
694
|
server.once('error', () => { exited = true; });
|
|
688
695
|
server.once('exit', () => { exited = true; });
|
|
@@ -746,7 +753,7 @@ const execFileAsync = promisify(execFile);
|
|
|
746
753
|
*/
|
|
747
754
|
export async function driveCoversSetup(cwd, setupBranch, driveBranch) {
|
|
748
755
|
try {
|
|
749
|
-
await execFileAsync('git', ['merge-base', '--is-ancestor', `refs/remotes/origin/${setupBranch}`, `refs/remotes/origin/${driveBranch}`], { cwd, timeout: 10_000 });
|
|
756
|
+
await execFileAsync('git', ['merge-base', '--is-ancestor', `refs/remotes/origin/${setupBranch}`, `refs/remotes/origin/${driveBranch}`], { cwd, timeout: 10_000, env: gitEnvironment() });
|
|
750
757
|
return true;
|
|
751
758
|
}
|
|
752
759
|
catch (err) {
|
|
@@ -981,11 +988,11 @@ export function workflowGitEnvironment() {
|
|
|
981
988
|
'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_CURL_VERBOSE', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG',
|
|
982
989
|
'GIT_CEILING_DIRECTORIES', 'GIT_DISCOVERY_ACROSS_FILESYSTEM', 'GIT_PREFIX', 'GIT_ASKPASS', 'SSH_ASKPASS'].includes(key))
|
|
983
990
|
delete env[key];
|
|
984
|
-
return Object.assign(env, { GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1', GCM_INTERACTIVE: 'never',
|
|
991
|
+
return gitEnvironment(Object.assign(env, { GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1', GCM_INTERACTIVE: 'never',
|
|
985
992
|
GIT_ASKPASS: '', SSH_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never', GIT_SSH_VARIANT: 'ssh',
|
|
986
993
|
GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=yes',
|
|
987
994
|
// Removing the variables alone still permits globally configured trace2 targets.
|
|
988
|
-
GIT_TRACE2: '0', GIT_TRACE2_EVENT: '0', GIT_TRACE2_PERF: '0' });
|
|
995
|
+
GIT_TRACE2: '0', GIT_TRACE2_EVENT: '0', GIT_TRACE2_PERF: '0' }));
|
|
989
996
|
}
|
|
990
997
|
const record = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
|
|
991
998
|
export function workflowDefaultBranch(value) {
|
|
@@ -1413,16 +1420,16 @@ export function createTools(ctx) {
|
|
|
1413
1420
|
let provenHost;
|
|
1414
1421
|
let savedHost;
|
|
1415
1422
|
/**
|
|
1416
|
-
* A host's build, waited for on the screen as the task in progress with
|
|
1417
|
-
* `built` says the evidence is final, or the wait runs out. The status line names the host and what it builds.
|
|
1423
|
+
* A host's build, waited for on the screen as the task in progress, naming `host`, with the time so far: `read` again
|
|
1424
|
+
* every so often until `built` says the evidence is final, or the wait runs out. The status line names the host and what it builds.
|
|
1418
1425
|
*/
|
|
1419
|
-
async function awaitBuild(status, read, built, signal) {
|
|
1426
|
+
async function awaitBuild(host, status, read, built, signal) {
|
|
1420
1427
|
let evidence = await read();
|
|
1421
1428
|
if (built(evidence))
|
|
1422
1429
|
return evidence;
|
|
1423
1430
|
const wait = ctx.previewWait ?? { totalMs: 300_000, everyMs: 15_000 };
|
|
1424
1431
|
said(status);
|
|
1425
|
-
reportWait({ since: Date.now(),
|
|
1432
|
+
reportWait({ since: Date.now(), host });
|
|
1426
1433
|
try {
|
|
1427
1434
|
for (const end = Date.now() + wait.totalMs; !built(evidence) && Date.now() < end; evidence = await read()) {
|
|
1428
1435
|
await sleep(wait.everyMs, undefined, { signal });
|
|
@@ -1528,6 +1535,12 @@ export function createTools(ctx) {
|
|
|
1528
1535
|
const hostSignIns = new Map();
|
|
1529
1536
|
/** The teams a host's sign-in offered, each by name and slug: the choices a `<host>-team` question shows, whatever the model lists, so the pick the fence takes is a slug. */
|
|
1530
1537
|
const teamChoices = new Map();
|
|
1538
|
+
/** A host's setup ended before anything was set up on the host: nothing more is asked for it, and the fence lets its CLI and its hold on pushes go. */
|
|
1539
|
+
const endSetup = (host) => {
|
|
1540
|
+
hostSignIns.set(host, 'skipped');
|
|
1541
|
+
teamChoices.delete(host);
|
|
1542
|
+
endHostSetup(ctx.log, host);
|
|
1543
|
+
};
|
|
1531
1544
|
const HOST_NAMES = { netlify: 'Netlify', vercel: 'Vercel' };
|
|
1532
1545
|
const hostCli = (host, pi, signal) => {
|
|
1533
1546
|
const runner = (browser = false) => (ctx.hostRunner ?? commandRunner)(host, pi.cwd, { signal, browser, timeoutMs: browser ? 300_000 : 30_000 });
|
|
@@ -1577,7 +1590,7 @@ export function createTools(ctx) {
|
|
|
1577
1590
|
if (!hostSignIns.has(host)) {
|
|
1578
1591
|
const answer = await ask({ id: `${host}-setup`, ...HOST_QUESTIONS[`${host}-setup`] });
|
|
1579
1592
|
if (answer.skipped) {
|
|
1580
|
-
|
|
1593
|
+
endSetup(host);
|
|
1581
1594
|
return { skipped: true };
|
|
1582
1595
|
}
|
|
1583
1596
|
hostSignIns.set(host, 'confirmed');
|
|
@@ -1599,11 +1612,11 @@ export function createTools(ctx) {
|
|
|
1599
1612
|
if (status.cli === 'signed-in')
|
|
1600
1613
|
return signedIn();
|
|
1601
1614
|
if (attempt === 2) {
|
|
1602
|
-
|
|
1615
|
+
endSetup(host);
|
|
1603
1616
|
return { skipped: true, reason: `The ${HOST_NAMES[host]} sign-in did not complete.` };
|
|
1604
1617
|
}
|
|
1605
1618
|
if ((await ask(HOST_SCREENS[`${host}-signin-retry`])).skipped) {
|
|
1606
|
-
|
|
1619
|
+
endSetup(host);
|
|
1607
1620
|
return { skipped: true };
|
|
1608
1621
|
}
|
|
1609
1622
|
}
|
|
@@ -1742,7 +1755,7 @@ export function createTools(ctx) {
|
|
|
1742
1755
|
return [
|
|
1743
1756
|
tool('impeccable_workflow_access', 'Check workflow access', 'Check that the setup pull request can carry the workflow: reads the active GitHub CLI account and its scopes, the origin remote and the default branch, and refreshes origin/<defaultBranch>. Call it before branching. needsWorkflowScope true means the account lacks workflow access over HTTPS; base.status "refreshed" means origin/<defaultBranch> is current. Only the push itself proves Git may push.', NO_INPUT, (_p, pi, signal) => workflowAccessTool(pi, signal)),
|
|
1744
1757
|
tool('impeccable_sync_workflow', 'Update the preview workflow', 'Write the state\'s workflow to .github/workflows/impeccable-preview.yml byte for byte and remove the retired workflow the state names, after checking access as impeccable_workflow_access does. Call it once the settings are saved, and again after committing: stagedMatches and committedMatches true (and retiredRemoval staged and committed) prove the commit holds the exact bytes; null means nothing was compared. It refuses, changing nothing, when the file has the person\'s local edits. It never branches, stages, commits or pushes.', NO_INPUT, (_p, pi, signal) => syncWorkflowTool(pi, signal)),
|
|
1745
|
-
tool('impeccable_verify_preview', 'Check the local preview', 'Prove the preview on this machine before saving it: runs the steps in a throwaway copy of the checkout, with a throwaway database, and removes everything afterwards. For a server give start, port, paths and host: it starts the app and requests each path as localhost and as the tunnel host. For a static site give output: it checks the build wrote the entry pages. verified covers HTTP answers and files only, never rendering, sign-in or the CI runtime; a failure names its stage.', PREVIEW_VERIFICATION, async (p, pi, signal) => JSON.stringify(await verifyLocalPreview(p, pi.cwd, signal))),
|
|
1758
|
+
tool('impeccable_verify_preview', 'Check the local preview', 'Prove the preview on this machine before saving it: runs the steps in a throwaway copy of the checkout, with a throwaway database, and removes everything afterwards. For a server give start, port, paths and host: it starts the app and requests each path as localhost and as the tunnel host. For a static site give output: it checks the build wrote the entry pages. verified covers HTTP answers and files only, never rendering, sign-in or the CI runtime; a failure names its stage.', PREVIEW_VERIFICATION, async (p, pi, signal) => JSON.stringify(await verifyLocalPreview(p, pi.cwd, signal, ctx.sandbox))),
|
|
1746
1759
|
tool('impeccable_preview_run', 'Read the preview attempt', 'On a rerun, once impeccable_test_pr recorded the setup pull request: read its preview workflow\'s runs. Answers each attempt with its failed stage and a fixed reason (never log text), the three-attempt budget (used, remaining, historyKnown), and adds the attempts to the task list. Reading is free.', PREVIEW_RUN, async (p, pi, signal) => {
|
|
1747
1760
|
if (!retry.pr)
|
|
1748
1761
|
return 'Refused: record the setup pull request with impeccable_test_pr first.';
|
|
@@ -1884,8 +1897,12 @@ export function createTools(ctx) {
|
|
|
1884
1897
|
if (error)
|
|
1885
1898
|
return `Invalid input: ${error}.`;
|
|
1886
1899
|
const answer = await askPerson(q);
|
|
1887
|
-
if (answer.skipped)
|
|
1900
|
+
if (answer.skipped) {
|
|
1901
|
+
// Without a terminal the Vercel CLI names no team on its own, so a skipped team ends the Vercel setup.
|
|
1902
|
+
if (host?.[1] === 'vercel')
|
|
1903
|
+
endSetup('vercel');
|
|
1888
1904
|
return JSON.stringify({ skipped: true });
|
|
1905
|
+
}
|
|
1889
1906
|
if (q.kind === 'multi')
|
|
1890
1907
|
return JSON.stringify({ values: answer.values ?? [] });
|
|
1891
1908
|
if (q.kind === 'confirm') {
|
|
@@ -1986,7 +2003,7 @@ export function createTools(ctx) {
|
|
|
1986
2003
|
const ofBranch = (d) => (branch !== undefined ? d.vercel : isPreview(d));
|
|
1987
2004
|
// A head's build is still building until it has a final status.
|
|
1988
2005
|
const final = (evidence) => Boolean(evidence.error) || evidence.deployments.some((d) => ofBranch(d) && (d.state === 'success' || d.state === 'failure' || d.state === 'error'));
|
|
1989
|
-
const evidence = branch !== undefined ? await awaitBuild(`Waiting for Vercel to build ${testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`}`, read, final, signal) : await read();
|
|
2006
|
+
const evidence = branch !== undefined ? await awaitBuild('Vercel', `Waiting for Vercel to build ${testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`}`, read, final, signal) : await read();
|
|
1990
2007
|
listedPreviews.clear();
|
|
1991
2008
|
vercelMade.clear();
|
|
1992
2009
|
publicProduction.clear();
|
|
@@ -2019,24 +2036,24 @@ export function createTools(ctx) {
|
|
|
2019
2036
|
// A pull request's preview is still building until Netlify's deploy of it is built or has failed.
|
|
2020
2037
|
const states = ['success', 'ready', 'failure', 'error'];
|
|
2021
2038
|
const final = (evidence) => Boolean(evidence.error) || evidence.previews.some((preview) => states.includes(preview.state ?? ''));
|
|
2022
|
-
const evidence = pull !== undefined ? await awaitBuild(`Waiting for Netlify to build ${await subjectOf(pull)}`, read, final, signal) : await read();
|
|
2039
|
+
const evidence = pull !== undefined ? await awaitBuild('Netlify', `Waiting for Netlify to build ${await subjectOf(pull)}`, read, final, signal) : await read();
|
|
2023
2040
|
netlifyListed.clear();
|
|
2024
2041
|
for (const p of evidence.previews)
|
|
2025
2042
|
if (p.state === 'success' || p.state === 'ready')
|
|
2026
2043
|
netlifyListed.add(new URL(p.url).origin);
|
|
2027
2044
|
return JSON.stringify(evidence);
|
|
2028
2045
|
}),
|
|
2029
|
-
tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts, or
|
|
2046
|
+
tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts, or cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in). cli says whether cf is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or cf. With pull (a pull request of this run) or branch (a branch of this run, pushed, before its pull request exists), only that one's, waiting up to five minutes for its build to finish, under a status line with its timer. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
|
|
2030
2047
|
const pull = p.pull;
|
|
2031
2048
|
const branch = p.branch;
|
|
2032
2049
|
if (pull !== undefined && branch !== undefined)
|
|
2033
2050
|
return 'Invalid input: give pull or branch, not both.';
|
|
2034
2051
|
const read = () => (ctx.cloudflarePreviewReader ?? readCloudflarePreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, branch });
|
|
2035
|
-
// Cloudflare posts its check run once the build ended: until then the head lists nothing, or
|
|
2036
|
-
// GitHub's record unread ends the wait;
|
|
2052
|
+
// Cloudflare posts its check run once the build ended: until then the head lists nothing, or cf's row says it is building.
|
|
2053
|
+
// GitHub's record unread ends the wait; cf's alone does not, since the check run still says when the build ends.
|
|
2037
2054
|
const final = (evidence) => Boolean(evidence.unread?.includes('GitHub')) || evidence.previews.some((preview) => preview.state !== 'building');
|
|
2038
2055
|
const subject = branch !== undefined ? (testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`) : pull !== undefined ? await subjectOf(pull) : '';
|
|
2039
|
-
const evidence = subject ? await awaitBuild(`Waiting for Cloudflare Pages to build ${subject}`, read, final, signal) : await read();
|
|
2056
|
+
const evidence = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare Pages to build ${subject}`, read, final, signal) : await read();
|
|
2040
2057
|
cloudflareListed.clear();
|
|
2041
2058
|
for (const preview of evidence.previews) {
|
|
2042
2059
|
if (preview.state !== 'success')
|
|
@@ -2142,15 +2159,14 @@ export function createTools(ctx) {
|
|
|
2142
2159
|
const at = gated;
|
|
2143
2160
|
/** The address pattern the proof with the password answers, saved as the method once the password is. */
|
|
2144
2161
|
let pattern;
|
|
2145
|
-
const prove = async (password) => {
|
|
2146
|
-
const proof = await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, password });
|
|
2162
|
+
const prove = async (password, form = false) => {
|
|
2163
|
+
const proof = await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, password, form });
|
|
2147
2164
|
if (proof.proven && proof.pattern)
|
|
2148
2165
|
pattern = proof.pattern;
|
|
2149
2166
|
return proof;
|
|
2150
2167
|
};
|
|
2151
|
-
// A password in hand already: another deploy preview of the same site opens with it.
|
|
2152
|
-
if (passwordSaved && sitePassword && (await prove(sitePassword)).proven) {
|
|
2153
|
-
passwordSites.add(site);
|
|
2168
|
+
// A password in hand already: another deploy preview of the same site opens with it. Another site's lock is read for that site.
|
|
2169
|
+
if (passwordSaved && sitePassword && passwordSites.has(site) && (await prove(sitePassword)).proven) {
|
|
2154
2170
|
provenPattern = pattern ?? provenPattern;
|
|
2155
2171
|
return JSON.stringify(passwordSaved);
|
|
2156
2172
|
}
|
|
@@ -2197,7 +2213,7 @@ export function createTools(ctx) {
|
|
|
2197
2213
|
}
|
|
2198
2214
|
return JSON.stringify(passwordOutcome);
|
|
2199
2215
|
}),
|
|
2200
|
-
tool('impeccable_cloudflare_access', 'Cloudflare Access', 'Get past the Cloudflare Access login that impeccable_prove_preview met (gate "cloudflare-access") in front of a Cloudflare Pages preview, the way Cloudflare documents for tools: a service token, sent in two headers, that a Service Auth policy of the Access application allows. It does the rest as the person: one screen asking to create the token
|
|
2216
|
+
tool('impeccable_cloudflare_access', 'Cloudflare Access', 'Get past the Cloudflare Access login that impeccable_prove_preview met (gate "cloudflare-access") in front of a Cloudflare Pages preview, the way Cloudflare documents for tools: a service token, sent in two headers, that a Service Auth policy of the Access application allows. It does the rest as the person: one screen asking to create the token, Cloudflare\'s sign-in in the browser where cf, Cloudflare\'s CLI, is not signed in here with Access (with a retry), then the token named "Impeccable reviews" created through cf and allowed in the policy of the application in front of the preview; one screen taking a Cloudflare API token only where that sign-in cannot create it; on a skip, or where that cannot be done, one screen taking a pasted client id and secret. It opens the preview with the token and saves it for reviews. Answers {"status": …}: saved (the preview is proven, and pattern is the address pattern to save with the method; token says whether it was created or typed, application which Access application allows it, expires when a created one runs out, apiToken true when the person\'s Cloudflare API token created it), skipped (nothing was saved; reason says what stopped it), refused (the typed token did not open the preview; reason says what the address answered) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No token ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
2201
2217
|
if (!accessGated)
|
|
2202
2218
|
return 'Refused: this is for a Cloudflare Pages preview impeccable_cloudflare_previews listed, which impeccable_prove_preview answered with gate "cloudflare-access". Prove one first.';
|
|
2203
2219
|
if (accessOutcome?.status === 'skipped')
|
|
@@ -2228,6 +2244,10 @@ export function createTools(ctx) {
|
|
|
2228
2244
|
answer = await passCloudflareAccess({
|
|
2229
2245
|
url: at.url,
|
|
2230
2246
|
consent: async () => !(await ask(HOST_SCREENS['cloudflare-access'])).skipped,
|
|
2247
|
+
cf: cfRunner({}, ctx.hostRunner),
|
|
2248
|
+
login: cfRunner({ browser: true, timeoutMs: 300_000 }, ctx.hostRunner),
|
|
2249
|
+
retry: async () => !(await ask(HOST_SCREENS['cloudflare-signin-retry'])).skipped,
|
|
2250
|
+
status: said,
|
|
2231
2251
|
apiToken: async () => {
|
|
2232
2252
|
const typed = await ask(HOST_SCREENS['cloudflare-access-api']);
|
|
2233
2253
|
return typed.skipped ? undefined : typed.value;
|
|
@@ -2250,7 +2270,7 @@ export function createTools(ctx) {
|
|
|
2250
2270
|
accessProjects.clear();
|
|
2251
2271
|
accessToken = value;
|
|
2252
2272
|
accessStored = told.status === 'saved';
|
|
2253
|
-
accessMade = { ...(told.token ? { token: told.token } : {}), ...(told.application ? { application: told.application } : {}), ...(told.expires ? { expires: told.expires } : {}) };
|
|
2273
|
+
accessMade = { ...(told.token ? { token: told.token } : {}), ...(told.application ? { application: told.application } : {}), ...(told.expires ? { expires: told.expires } : {}), ...(told.apiToken ? { apiToken: told.apiToken } : {}) };
|
|
2254
2274
|
const project = pagesProject(new URL(at.url).origin);
|
|
2255
2275
|
if (project)
|
|
2256
2276
|
accessProjects.add(project);
|