@bridge4dev/runner 0.30.0 → 0.33.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.
- package/dist/adapters/claude.js +162 -45
- package/dist/adapters/codex.js +42 -14
- package/dist/adapters/types.d.ts +25 -1
- package/dist/agent-prompt.d.ts +75 -0
- package/dist/agent-prompt.js +252 -0
- package/dist/index.js +23 -0
- package/dist/policy.d.ts +73 -1
- package/dist/policy.js +398 -53
- package/dist/protocol.d.ts +240 -30
- package/dist/protocol.js +56 -0
- package/dist/supervisor.d.ts +29 -1
- package/dist/supervisor.js +186 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { isGitInternalPath, isSecretPath } from './policy.js';
|
|
5
|
+
/**
|
|
6
|
+
* The project's own prompt file — the one thing on this machine that a project
|
|
7
|
+
* can put into an agent's SYSTEM prompt.
|
|
8
|
+
*
|
|
9
|
+
* ## Why a system prompt and not a file the agent reads
|
|
10
|
+
*
|
|
11
|
+
* Measured on a live request (2026-08-05), not assumed. `CLAUDE.md`, `AGENTS.md`
|
|
12
|
+
* and `SessionStart` hook output all arrive inside the first USER message —
|
|
13
|
+
* Claude Code's own prompt says so in as many words: «Treat feedback from hooks
|
|
14
|
+
* … as coming from the user». `systemPrompt.append` arrives in `system[]`,
|
|
15
|
+
* after the CLI's own text and last. Only the second survives a compaction, and
|
|
16
|
+
* only the second can stand next to a rule the CLI itself states.
|
|
17
|
+
*
|
|
18
|
+
* That difference is the whole feature. An Opus-5 system prompt carries
|
|
19
|
+
* «Do not call the AgentTool unless the user requested it»; a project whose
|
|
20
|
+
* process REQUIRES an independent review round has to be able to say so at the
|
|
21
|
+
* same level, or it is simply outranked and nobody can see why.
|
|
22
|
+
*
|
|
23
|
+
* ## Why this file is paranoid
|
|
24
|
+
*
|
|
25
|
+
* Its contents become system-prompt text verbatim, and the path comes off the
|
|
26
|
+
* wire. So every rule is re-derived here rather than trusted from the API: the
|
|
27
|
+
* runner is the process that opens the file, and it is the only side that can
|
|
28
|
+
* see what the path actually resolves to on this disk.
|
|
29
|
+
*/
|
|
30
|
+
/** Big enough for a real process document; small enough to stay a prompt. */
|
|
31
|
+
export const AGENT_PROMPT_MAX_BYTES = 32 * 1024;
|
|
32
|
+
function deny(reason) {
|
|
33
|
+
return { ok: false, reason };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Read the project's prompt file, or explain why it cannot be read.
|
|
37
|
+
*
|
|
38
|
+
* @param projectRoot Absolute path of the PROJECT FOLDER (`workspace.path`) —
|
|
39
|
+
* not the session worktree. Deliberate, for two reasons that always hold: the
|
|
40
|
+
* file may be uncommitted (a worktree cut from the base branch would not have
|
|
41
|
+
* it), and every session of the project then reads the same rules whatever
|
|
42
|
+
* branch it is on.
|
|
43
|
+
*
|
|
44
|
+
* A third reason used to be written here and was wrong, so it is worth saying
|
|
45
|
+
* plainly (QA-130 MAJOR-3): this does NOT stop a session from rewriting the
|
|
46
|
+
* file that becomes its own next system prompt. It stops it in `BRANCH` mode,
|
|
47
|
+
* where writes outside the worktree are refused — but in `workMode: DIRECT`,
|
|
48
|
+
* which is the default, the project folder IS the session's working
|
|
49
|
+
* directory. What guards it there is the layer-1 rule in `policy.ts`
|
|
50
|
+
* (`agentPromptFile`), and that rule is a guard rather than a guarantee: it
|
|
51
|
+
* covers the file-writing tools, not a shell redirect, and `full` mode
|
|
52
|
+
* bypasses layer 1 altogether by design. The honest backstop is the `sha` in
|
|
53
|
+
* the session feed, which changes when the file does.
|
|
54
|
+
* @param relPath The configured path, relative to `projectRoot`.
|
|
55
|
+
*/
|
|
56
|
+
export function readAgentPrompt(projectRoot, relPath) {
|
|
57
|
+
const wanted = relPath.trim();
|
|
58
|
+
if (wanted === '')
|
|
59
|
+
return deny('the path is empty');
|
|
60
|
+
if (wanted.length > 300)
|
|
61
|
+
return deny('the path is too long');
|
|
62
|
+
// A NUL truncates the string every syscall below sees, so it must never get
|
|
63
|
+
// as far as a syscall.
|
|
64
|
+
// eslint-disable-next-line no-control-regex -- a control character in a path is never legitimate
|
|
65
|
+
if (/[\u0000-\u001f\u007f]/.test(wanted))
|
|
66
|
+
return deny('the path contains control characters');
|
|
67
|
+
if (path.isAbsolute(wanted) || wanted.startsWith('~')) {
|
|
68
|
+
return deny('the path must be relative to the project folder');
|
|
69
|
+
}
|
|
70
|
+
if (wanted.includes('\\'))
|
|
71
|
+
return deny('the path must use "/" separators');
|
|
72
|
+
if (wanted.endsWith('/'))
|
|
73
|
+
return deny('the path must name a file, not a directory');
|
|
74
|
+
if (wanted.split('/').includes('..'))
|
|
75
|
+
return deny('the path must not contain ".." segments');
|
|
76
|
+
// The project folder itself has to exist and be a directory before anything
|
|
77
|
+
// can be resolved against it — otherwise `realpath` failures below would all
|
|
78
|
+
// read as «no such file», blaming the prompt for a moved project.
|
|
79
|
+
let rootReal;
|
|
80
|
+
try {
|
|
81
|
+
rootReal = fs.realpathSync(path.resolve(projectRoot));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return deny('the project folder is not readable');
|
|
85
|
+
}
|
|
86
|
+
const absolute = path.resolve(rootReal, wanted);
|
|
87
|
+
// `lstat` first, and on the path as written: a SYMLINK is refused outright
|
|
88
|
+
// rather than followed. Following it would mean the setting says one file and
|
|
89
|
+
// the agent's system prompt comes from another, which is exactly the sort of
|
|
90
|
+
// indirection that makes a security rule unreadable.
|
|
91
|
+
let link;
|
|
92
|
+
try {
|
|
93
|
+
link = fs.lstatSync(absolute);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
return deny(describeFsError(error));
|
|
97
|
+
}
|
|
98
|
+
if (link.isSymbolicLink())
|
|
99
|
+
return deny('the path is a symlink');
|
|
100
|
+
if (link.isDirectory())
|
|
101
|
+
return deny('the path is a directory');
|
|
102
|
+
if (!link.isFile())
|
|
103
|
+
return deny('the path is not a regular file');
|
|
104
|
+
// Containment is checked AFTER symlinks are resolved, because a symlinked
|
|
105
|
+
// PARENT directory would otherwise pass a purely textual check and land the
|
|
106
|
+
// read outside the project entirely (the QA-96 F13 lesson, applied here).
|
|
107
|
+
let realPath;
|
|
108
|
+
try {
|
|
109
|
+
realPath = fs.realpathSync(absolute);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
return deny(describeFsError(error));
|
|
113
|
+
}
|
|
114
|
+
const rel = path.relative(rootReal, realPath);
|
|
115
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
116
|
+
return deny('the path leaves the project folder');
|
|
117
|
+
}
|
|
118
|
+
// The same two lists layer 1 uses for every tool call. A setting must not be
|
|
119
|
+
// a way around them: «read .env into the system prompt» is precisely the
|
|
120
|
+
// shape of request this would otherwise grant.
|
|
121
|
+
if (isSecretPath(realPath))
|
|
122
|
+
return deny('that file is on the protected list');
|
|
123
|
+
if (isGitInternalPath(realPath))
|
|
124
|
+
return deny('that file is inside .git');
|
|
125
|
+
// Read with a hard ceiling on BYTES ACTUALLY READ rather than trusting the
|
|
126
|
+
// size from `stat`: a file can grow between the two calls, and «we checked
|
|
127
|
+
// and then read whatever was there» is not a limit.
|
|
128
|
+
//
|
|
129
|
+
// The open re-checks the file through its own descriptor rather than trusting
|
|
130
|
+
// the path a second time, and that closes two holes the path checks cannot
|
|
131
|
+
// (QA-130 MINOR-4):
|
|
132
|
+
//
|
|
133
|
+
// - **the window between `realpath` and `open`.** Every rule above was
|
|
134
|
+
// decided about a path; `open` resolves the name again. Replacing the last
|
|
135
|
+
// component with a symlink in between would hand back a file nothing here
|
|
136
|
+
// ever looked at. `O_NOFOLLOW` refuses that outright, and comparing
|
|
137
|
+
// dev/ino catches a plain swap.
|
|
138
|
+
// - **hard links**, which are the same capability under another name and
|
|
139
|
+
// which `realpath` cannot see: a link has no target to resolve. Without
|
|
140
|
+
// this, `ln /etc/hostname docs/prompt.md` walks past both «inside the
|
|
141
|
+
// project folder» and «not on the protected list», because both were
|
|
142
|
+
// decided about the NAME.
|
|
143
|
+
let buffer;
|
|
144
|
+
try {
|
|
145
|
+
buffer = readCapped(realPath, AGENT_PROMPT_MAX_BYTES + 1, link);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (error instanceof PromptFileRefused)
|
|
149
|
+
return deny(error.reason);
|
|
150
|
+
return deny(describeFsError(error));
|
|
151
|
+
}
|
|
152
|
+
if (buffer.length > AGENT_PROMPT_MAX_BYTES) {
|
|
153
|
+
return deny(`the file is larger than ${Math.floor(AGENT_PROMPT_MAX_BYTES / 1024)} KB`);
|
|
154
|
+
}
|
|
155
|
+
if (buffer.includes(0))
|
|
156
|
+
return deny('the file is not text');
|
|
157
|
+
const sha = crypto.createHash('sha256').update(buffer).digest('hex').slice(0, 12);
|
|
158
|
+
// Strip a UTF-8 BOM: it is invisible in every editor and would otherwise be
|
|
159
|
+
// the first character of the agent's system prompt.
|
|
160
|
+
const text = buffer
|
|
161
|
+
.toString('utf8')
|
|
162
|
+
.replace(/^\uFEFF/, '')
|
|
163
|
+
.trim();
|
|
164
|
+
if (text === '')
|
|
165
|
+
return deny('the file is empty');
|
|
166
|
+
return { ok: true, text, relPath: wanted, absPath: realPath, bytes: buffer.length, sha };
|
|
167
|
+
}
|
|
168
|
+
/** A refusal decided after the file was already open — carried out by throwing. */
|
|
169
|
+
class PromptFileRefused extends Error {
|
|
170
|
+
reason;
|
|
171
|
+
constructor(reason) {
|
|
172
|
+
super(reason);
|
|
173
|
+
this.reason = reason;
|
|
174
|
+
this.name = 'PromptFileRefused';
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Read at most `limit` bytes from a file that is proved, through its own
|
|
179
|
+
* descriptor, to be the same plain file the checks above approved.
|
|
180
|
+
*
|
|
181
|
+
* @param expected The `lstat` taken before the path checks — the identity every
|
|
182
|
+
* rule above was decided about.
|
|
183
|
+
*/
|
|
184
|
+
function readCapped(file, limit, expected) {
|
|
185
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
186
|
+
try {
|
|
187
|
+
const opened = fs.fstatSync(fd);
|
|
188
|
+
if (!opened.isFile())
|
|
189
|
+
throw new PromptFileRefused('the path is not a regular file');
|
|
190
|
+
// A second name for the same inode is a second way in, and it is the one
|
|
191
|
+
// `realpath` cannot see. One link is the only shape whose name and contents
|
|
192
|
+
// are the same fact.
|
|
193
|
+
if (opened.nlink !== 1)
|
|
194
|
+
throw new PromptFileRefused('the file has more than one name');
|
|
195
|
+
if (opened.dev !== expected.dev || opened.ino !== expected.ino) {
|
|
196
|
+
throw new PromptFileRefused('the file changed while it was being opened');
|
|
197
|
+
}
|
|
198
|
+
const buffer = Buffer.alloc(limit);
|
|
199
|
+
let read = 0;
|
|
200
|
+
while (read < limit) {
|
|
201
|
+
const n = fs.readSync(fd, buffer, read, limit - read, null);
|
|
202
|
+
if (n === 0)
|
|
203
|
+
break;
|
|
204
|
+
read += n;
|
|
205
|
+
}
|
|
206
|
+
return buffer.subarray(0, read);
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
fs.closeSync(fd);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Turn an fs error into a sentence a human can act on.
|
|
214
|
+
*
|
|
215
|
+
* Never echoes the error's own message: it carries the absolute path, and this
|
|
216
|
+
* text is shown in the session feed of a dashboard that may be open on somebody
|
|
217
|
+
* else's screen.
|
|
218
|
+
*/
|
|
219
|
+
function describeFsError(error) {
|
|
220
|
+
const code = error?.code;
|
|
221
|
+
if (code === 'ENOENT')
|
|
222
|
+
return 'no such file in the project folder';
|
|
223
|
+
if (code === 'EACCES' || code === 'EPERM')
|
|
224
|
+
return 'the runner may not read that file';
|
|
225
|
+
if (code === 'EISDIR')
|
|
226
|
+
return 'the path is a directory';
|
|
227
|
+
if (code === 'ELOOP')
|
|
228
|
+
return 'the path is a symlink loop';
|
|
229
|
+
if (code === 'ENAMETOOLONG')
|
|
230
|
+
return 'the path is too long';
|
|
231
|
+
return 'the file could not be read';
|
|
232
|
+
}
|
|
233
|
+
/** How the prompt is announced in the session feed and in the journal. */
|
|
234
|
+
export function agentPromptSizeLabel(bytes) {
|
|
235
|
+
return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* A configured path, safe to put in a line a person reads.
|
|
239
|
+
*
|
|
240
|
+
* The session feed renders a notice as plain text, so backticks would be shown
|
|
241
|
+
* literally rather than as code — and this string can be a REFUSED path, which
|
|
242
|
+
* means it never passed any of the checks above and is only as clean as the API
|
|
243
|
+
* schema made it. Quoted with guillemets, stripped of anything that could break
|
|
244
|
+
* a line, and bounded (QA-130 NIT-13).
|
|
245
|
+
*/
|
|
246
|
+
export function quotePath(value) {
|
|
247
|
+
const clean = Array.from(value)
|
|
248
|
+
.filter((ch) => ch >= ' ' && ch !== '\u007f' && ch !== '`')
|
|
249
|
+
.join('');
|
|
250
|
+
return `«${clean.length > 120 ? `${clean.slice(0, 117)}…` : clean}»`;
|
|
251
|
+
}
|
|
252
|
+
//# sourceMappingURL=agent-prompt.js.map
|
package/dist/index.js
CHANGED
|
@@ -249,6 +249,16 @@ function runnerCapabilities(apiUrlOverride) {
|
|
|
249
249
|
*/
|
|
250
250
|
contextRewind: true,
|
|
251
251
|
contextCompaction: true,
|
|
252
|
+
/**
|
|
253
|
+
* Reads the project's own prompt file and hands it to the agent as
|
|
254
|
+
* SYSTEM-prompt text, for both Claude and Codex.
|
|
255
|
+
*
|
|
256
|
+
* Announced so the dashboard can refuse to offer the setting on a server
|
|
257
|
+
* that would ignore it. That refusal is the point of the flag: this is the
|
|
258
|
+
* one setting whose value is that it cannot be silently outranked, so a
|
|
259
|
+
* version of it that is silently ignored would be worse than none.
|
|
260
|
+
*/
|
|
261
|
+
agentPrompt: true,
|
|
252
262
|
/**
|
|
253
263
|
* Session 15: `git_status` reports whether the PROJECT FOLDER is clean, so
|
|
254
264
|
* the panel can say what is blocking an Apply instead of offering a button
|
|
@@ -265,6 +275,19 @@ function runnerCapabilities(apiUrlOverride) {
|
|
|
265
275
|
* runner incapable of the setting it honours.
|
|
266
276
|
*/
|
|
267
277
|
agentAutoCommit: true,
|
|
278
|
+
/**
|
|
279
|
+
* Session 18: the agent's git rules come from the PROJECT, not from this
|
|
280
|
+
* binary.
|
|
281
|
+
*
|
|
282
|
+
* `agentPushBan`, `agentProtectedBranches`, `agentAllowForcePush` and
|
|
283
|
+
* `agentAllowDestructiveGit` are read off the workspace and applied on
|
|
284
|
+
* every tool call. Announced so the settings card can warn that a server
|
|
285
|
+
* without it will go on refusing every push whatever the switch says —
|
|
286
|
+
* warn, not refuse: the fleet updates one machine at a time and a manager
|
|
287
|
+
* who cannot record the intention until the last of them is current has
|
|
288
|
+
* been handed a worse problem.
|
|
289
|
+
*/
|
|
290
|
+
agentGitPolicy: true,
|
|
268
291
|
/**
|
|
269
292
|
* Session 16: git is git.
|
|
270
293
|
*
|
package/dist/policy.d.ts
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
import type { AgentMode } from './adapters/types.js';
|
|
2
2
|
export type TrustMode = 'STRICT' | 'NORMAL' | 'AUTO';
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* The project's git policy for agents (session 18) — one shape, carried
|
|
5
|
+
* unchanged from the API's descriptor through the supervisor and the adapters
|
|
6
|
+
* into `PolicyContext`.
|
|
7
|
+
*
|
|
8
|
+
* A named interface rather than four fields repeated in five places, because
|
|
9
|
+
* these are the fields whose DIRECTION matters: see the polarity note on
|
|
10
|
+
* `agentPushBan`. A copy that drifts is a copy that eventually gets one of
|
|
11
|
+
* them the wrong way round.
|
|
12
|
+
*/
|
|
13
|
+
export interface AgentGitPolicy {
|
|
14
|
+
/**
|
|
15
|
+
* «Принудительно запретить push» — the project's switch.
|
|
16
|
+
*
|
|
17
|
+
* READ AS `!== false`. Not `=== true`, and not truthiness.
|
|
18
|
+
*
|
|
19
|
+
* `undefined` means an API too old to send the field, and there the answer
|
|
20
|
+
* must be what it has always been: refused. This is the OPPOSITE polarity to
|
|
21
|
+
* `agentAutoCommit`, where silence permits — and they sit close together on
|
|
22
|
+
* purpose so that whoever copies one reads why the other differs. Getting it
|
|
23
|
+
* backwards does not fail a test, it silently hands every runner on an old
|
|
24
|
+
* API permission to push.
|
|
25
|
+
*/
|
|
26
|
+
agentPushBan?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Branches the agent may never push to, whatever `agentPushBan` says.
|
|
29
|
+
*
|
|
30
|
+
* `undefined` means «nobody said», which is `['main', 'master']` — the pair
|
|
31
|
+
* that was hardcoded until this release. An EMPTY ARRAY is a real answer and
|
|
32
|
+
* means «nothing is protected»; the two must not be conflated, which is why
|
|
33
|
+
* nothing here writes `list?.length ? … : DEFAULT`.
|
|
34
|
+
*/
|
|
35
|
+
agentProtectedBranches?: string[];
|
|
36
|
+
/**
|
|
37
|
+
* `undefined` and `false` both mean no. Force into a protected branch is
|
|
38
|
+
* refused whatever this says — the two rules are AND-ed, not OR-ed.
|
|
39
|
+
*/
|
|
40
|
+
agentAllowForcePush?: boolean;
|
|
41
|
+
/** `git reset --hard` and `git clean`. `undefined` and `false` mean no. */
|
|
42
|
+
agentAllowDestructiveGit?: boolean;
|
|
43
|
+
}
|
|
44
|
+
export interface PolicyContext extends AgentGitPolicy {
|
|
4
45
|
trustMode: TrustMode;
|
|
5
46
|
/**
|
|
6
47
|
* The interaction mode the SESSION is in — the dial in the composer, as
|
|
@@ -30,6 +71,27 @@ export interface PolicyContext {
|
|
|
30
71
|
* than this release — and there the answer stays what it has always been.
|
|
31
72
|
*/
|
|
32
73
|
agentAutoCommit?: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Absolute path of the project's own prompt file, when this session was given
|
|
76
|
+
* one (session 17).
|
|
77
|
+
*
|
|
78
|
+
* Same shape and the same reason as the `.git` rule below it: this file has
|
|
79
|
+
* stopped being data. Its contents are the session's SYSTEM prompt, so a
|
|
80
|
+
* write to it is a rewrite of the rules the next process of this session will
|
|
81
|
+
* be given — including the rules that say what the agent may do.
|
|
82
|
+
*
|
|
83
|
+
* It only became reachable when `workMode: DIRECT` made the project folder
|
|
84
|
+
* the agent's own working directory (session 16, and the default). In
|
|
85
|
+
* `BRANCH` mode «writes outside the worktree are not allowed» already covered
|
|
86
|
+
* it, which is why the first version of session 17 believed it was safe
|
|
87
|
+
* everywhere (QA-130 MAJOR-3).
|
|
88
|
+
*
|
|
89
|
+
* A guard, not a guarantee, and the difference is worth stating: it covers
|
|
90
|
+
* the file-writing tools, not `sh -c 'echo … > prompt.md'`, and `full` mode
|
|
91
|
+
* does not consult layer 1 at all. The `sha` printed in the session feed is
|
|
92
|
+
* what makes a change visible when this cannot prevent it.
|
|
93
|
+
*/
|
|
94
|
+
agentPromptFile?: string;
|
|
33
95
|
}
|
|
34
96
|
export interface PolicyDecision {
|
|
35
97
|
decision: 'allow' | 'deny' | 'ask';
|
|
@@ -55,6 +117,16 @@ export declare function isInsideWorktree(p: string, worktreePath: string): boole
|
|
|
55
117
|
* next git invocation. Neither is ever ordinary agent work.
|
|
56
118
|
*/
|
|
57
119
|
export declare function isGitInternalPath(p: string): boolean;
|
|
120
|
+
/**
|
|
121
|
+
* The project's git rules, applied to one Bash command.
|
|
122
|
+
*
|
|
123
|
+
* Returns a refusal, or null when the command has nothing to do with them.
|
|
124
|
+
* Every branch of this function is reachable only when the project has
|
|
125
|
+
* DELIBERATELY switched something on — with the shipped defaults the first
|
|
126
|
+
* check refuses every push and the rest never run, which is byte for byte the
|
|
127
|
+
* behaviour of the four regexes it replaces.
|
|
128
|
+
*/
|
|
129
|
+
export declare function evaluateGitPolicy(command: string, ctx: PolicyContext): PolicyDecision | null;
|
|
58
130
|
export interface RecipeCommandContext {
|
|
59
131
|
/**
|
|
60
132
|
* The docker compose project this preview owns, from `preview.project` in the
|