@intentic/sandbox-contract 1.233.0 → 1.235.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/README.md +4 -3
- package/dist/agent-catalog.d.ts +2 -1
- package/dist/agent-catalog.d.ts.map +1 -1
- package/dist/agent-catalog.js +19 -13
- package/dist/agent-catalog.js.map +1 -1
- package/dist/chores/chores.d.ts.map +1 -1
- package/dist/chores/chores.js +36 -1
- package/dist/chores/chores.js.map +1 -1
- package/dist/chores/probes.d.ts.map +1 -1
- package/dist/chores/probes.js +70 -0
- package/dist/chores/probes.js.map +1 -1
- package/dist/command-classes.d.ts +5 -2
- package/dist/command-classes.d.ts.map +1 -1
- package/dist/command-classes.js +34 -13
- package/dist/command-classes.js.map +1 -1
- package/dist/contracts/automations.contract.d.ts +30 -0
- package/dist/contracts/automations.contract.d.ts.map +1 -1
- package/dist/contracts/chores.contract.d.ts +17 -0
- package/dist/contracts/chores.contract.d.ts.map +1 -1
- package/dist/contracts/extensions.contract.d.ts +2 -0
- package/dist/contracts/extensions.contract.d.ts.map +1 -1
- package/dist/contracts/extensions.contract.js.map +1 -1
- package/dist/contracts/issues.contract.d.ts +89 -0
- package/dist/contracts/issues.contract.d.ts.map +1 -0
- package/dist/contracts/issues.contract.js +50 -0
- package/dist/contracts/issues.contract.js.map +1 -0
- package/dist/contracts/runner.contract.d.ts +102 -102
- package/dist/contracts/settings.contract.d.ts +12 -0
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.d.ts +12 -0
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.js +10 -1
- package/dist/contracts/system.contract.js.map +1 -1
- package/dist/credential-material.d.ts +2 -0
- package/dist/credential-material.d.ts.map +1 -0
- package/dist/credential-material.js +36 -0
- package/dist/credential-material.js.map +1 -0
- package/dist/definition.d.ts +8 -0
- package/dist/definition.d.ts.map +1 -1
- package/dist/history-state.d.ts.map +1 -1
- package/dist/history-state.js +1 -0
- package/dist/history-state.js.map +1 -1
- package/dist/hostnames.d.ts +2 -0
- package/dist/hostnames.d.ts.map +1 -1
- package/dist/hostnames.js +3 -1
- package/dist/hostnames.js.map +1 -1
- package/dist/index.d.ts +280 -116
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/ingress-contract.d.ts +23 -0
- package/dist/ingress-contract.d.ts.map +1 -0
- package/dist/ingress-contract.js +43 -0
- package/dist/ingress-contract.js.map +1 -0
- package/dist/runtime-state.d.ts +9 -0
- package/dist/runtime-state.d.ts.map +1 -1
- package/dist/runtime-state.js +3 -0
- package/dist/runtime-state.js.map +1 -1
- package/dist/schemas/agent.d.ts +6 -0
- package/dist/schemas/agent.d.ts.map +1 -1
- package/dist/schemas/agent.js +2 -1
- package/dist/schemas/agent.js.map +1 -1
- package/dist/schemas/automations.d.ts +45 -0
- package/dist/schemas/automations.d.ts.map +1 -1
- package/dist/schemas/automations.js +6 -1
- package/dist/schemas/automations.js.map +1 -1
- package/dist/schemas/computers.d.ts +41 -0
- package/dist/schemas/computers.d.ts.map +1 -1
- package/dist/schemas/computers.js +16 -0
- package/dist/schemas/computers.js.map +1 -1
- package/dist/schemas/extension-updates.d.ts +2 -0
- package/dist/schemas/extension-updates.d.ts.map +1 -1
- package/dist/schemas/extension-updates.js +3 -1
- package/dist/schemas/extension-updates.js.map +1 -1
- package/dist/schemas/issues.d.ts +324 -0
- package/dist/schemas/issues.d.ts.map +1 -0
- package/dist/schemas/issues.js +107 -0
- package/dist/schemas/issues.js.map +1 -0
- package/dist/schemas/maintenance.d.ts +62 -1
- package/dist/schemas/maintenance.d.ts.map +1 -1
- package/dist/schemas/maintenance.js +22 -1
- package/dist/schemas/maintenance.js.map +1 -1
- package/dist/schemas/settings.d.ts +6 -0
- package/dist/schemas/settings.d.ts.map +1 -1
- package/dist/schemas/settings.js +4 -0
- package/dist/schemas/settings.js.map +1 -1
- package/dist/schemas/terminal.d.ts.map +1 -1
- package/dist/schemas/terminal.js.map +1 -1
- package/dist/schemas/webext.d.ts +24 -0
- package/dist/schemas/webext.d.ts.map +1 -1
- package/dist/schemas/webext.js +9 -0
- package/dist/schemas/webext.js.map +1 -1
- package/dist/webext-links.d.ts +1 -0
- package/dist/webext-links.d.ts.map +1 -1
- package/dist/webext-links.js +1 -0
- package/dist/webext-links.js.map +1 -1
- package/dist/workspace-state.d.ts +10 -6
- package/dist/workspace-state.d.ts.map +1 -1
- package/dist/workspace-state.js +13 -8
- package/dist/workspace-state.js.map +1 -1
- package/package.json +30 -8
- package/src/agent-catalog.test.ts +55 -54
- package/src/agent-catalog.ts +29 -22
- package/src/capability-ledger.test.ts +11 -2
- package/src/chores/chores.test.ts +1 -1
- package/src/chores/chores.ts +66 -1
- package/src/chores/probes.test.ts +65 -0
- package/src/chores/probes.ts +133 -1
- package/src/chores/verdict.test.ts +29 -19
- package/src/command-classes.test.ts +71 -4
- package/src/command-classes.ts +112 -22
- package/src/contracts/extensions.contract.ts +3 -2
- package/src/contracts/issues.contract.ts +60 -0
- package/src/contracts/system.contract.ts +22 -1
- package/src/credential-material.test.ts +120 -0
- package/src/credential-material.ts +100 -0
- package/src/history-state.ts +12 -0
- package/src/hostnames.ts +18 -2
- package/src/index.ts +5 -0
- package/src/ingress-contract.test.ts +65 -0
- package/src/ingress-contract.ts +155 -0
- package/src/routes.test.ts +4 -2
- package/src/runtime-state.ts +16 -0
- package/src/schemas/agent.ts +15 -2
- package/src/schemas/automations.ts +14 -2
- package/src/schemas/computers.ts +60 -0
- package/src/schemas/extension-updates.ts +3 -1
- package/src/schemas/issues.ts +279 -0
- package/src/schemas/maintenance.ts +51 -1
- package/src/schemas/settings.ts +32 -0
- package/src/schemas/terminal.ts +6 -4
- package/src/schemas/webext.ts +29 -0
- package/src/tunnel-ids.test.ts +3 -1
- package/src/webext-links.ts +10 -0
- package/src/workspace-state.test.ts +3 -1
- package/src/workspace-state.ts +27 -37
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { AgentHarnessSchema, AgentOriginSchema, AgentProviderSchema } from "./agent.js";
|
|
4
4
|
import { AgentSummarySchema } from "./agents.js";
|
|
5
5
|
import { entryId } from "./internal.js";
|
|
6
|
+
import { IssuesConfigSchema } from "./issues.js";
|
|
6
7
|
// An automation wakes the agent autonomously: the daemon's scheduler fires each enabled automation on its
|
|
7
8
|
// trigger, runs the optional guard command (a shell command in the workspace; non-zero exit skips the wake),
|
|
8
9
|
// then runs one agent turn with the prompt. The manifest is user config; run history is daemon-recorded.
|
|
@@ -108,8 +109,14 @@ export const TriggerSchema = z.discriminatedUnion("kind", [
|
|
|
108
109
|
.min(1)
|
|
109
110
|
.optional()
|
|
110
111
|
.describe("Narrow it to one branch, for the sources that have branches. Absent means every branch of the repositories it matches."),
|
|
111
|
-
|
|
112
|
-
|
|
112
|
+
/* The two gateway-less browser sources, `webchat` and `issues`: the website origins allowed to POST to
|
|
113
|
+
* the public endpoint. Absent/empty ⇒ none admitted, on both. One field rather than one per source,
|
|
114
|
+
* because it is the same question asked of the same header by the same kind of caller, and an intake
|
|
115
|
+
* whose allowlist lived somewhere else would be a second gate to keep in step with the first. */
|
|
116
|
+
allowedOrigins: z
|
|
117
|
+
.array(z.string())
|
|
118
|
+
.optional()
|
|
119
|
+
.describe("Which websites may reach the public endpoint, the chat widget's or the bug reporter's. Absent or empty admits nobody."),
|
|
113
120
|
}),
|
|
114
121
|
// `repo` narrows to events whose span touches one workspace repo ("root" or a repo id); absent ⇒ any.
|
|
115
122
|
z.object({
|
|
@@ -266,6 +273,11 @@ export const AutomationSchema = z.object({
|
|
|
266
273
|
prompt: z.string().min(1).describe("What the woken agent is told."),
|
|
267
274
|
// The Front Desk widget's settings, `webchat` listener automations only, ignored on every other trigger.
|
|
268
275
|
webchat: WebchatConfigSchema.optional().describe("Settings for the public chat widget, for an automation that answers visitors."),
|
|
276
|
+
// The bug intake's settings, `issues` listener automations only, ignored on every other trigger. Its own
|
|
277
|
+
// field rather than a shared "public endpoint" bag: the two sources answer different questions (a chat's
|
|
278
|
+
// greeting and access model, an intake's dedup ceiling and ingest key) and a union of both would be a
|
|
279
|
+
// schema where most fields are wrong for whichever source is reading it.
|
|
280
|
+
issues: IssuesConfigSchema.optional().describe("Settings for the bug reporter, for an automation that takes crash reports from your own sites and apps."),
|
|
269
281
|
/* NARROW THIS ONE JOB FURTHER than the persona it runs as, raw tool names, and the escape hatch under the
|
|
270
282
|
* shelves rather than the way anyone is expected to answer this question.
|
|
271
283
|
*
|
package/src/schemas/computers.ts
CHANGED
|
@@ -117,6 +117,53 @@ export const MachineFlowLineSchema = z.discriminatedUnion("kind", [
|
|
|
117
117
|
z.object({ kind: z.literal("error"), message: z.string() }),
|
|
118
118
|
]);
|
|
119
119
|
export type MachineFlowLine = z.infer<typeof MachineFlowLineSchema>;
|
|
120
|
+
/* RUNNING ONE OF THIS PRODUCT'S OWN CLIs ON A CONNECTED COMPUTER, FROM A BUTTON, with no agent in the loop.
|
|
121
|
+
*
|
|
122
|
+
* A machine that is connected as a computer can already be told things: the ops above drive its docker, and an
|
|
123
|
+
* agent with the `host` capability can run whatever it likes through `run_command`. What had no door was the
|
|
124
|
+
* ordinary case in between — the user wants the thing the CLI on their machine already does, and the sandbox is
|
|
125
|
+
* where they are looking. Their alternatives were to go and find a terminal, or to ask an agent to do it, which
|
|
126
|
+
* spends a turn and a model's judgement on a decision that has none in it.
|
|
127
|
+
*
|
|
128
|
+
* So: a CLOSED SET OF NAMES, and the argv is built on the daemon from the name alone (hosts/machine-commands.ts).
|
|
129
|
+
* The browser sends `mirror-off`, never a command line. That is the whole security property, and it is the
|
|
130
|
+
* reason this is an enum rather than a string: the same socket carries `run_command`, so a route that forwarded
|
|
131
|
+
* caller-supplied text would hand every browser session a shell on the user's laptop, which is a grant the
|
|
132
|
+
* capability card never made.
|
|
133
|
+
*
|
|
134
|
+
* The machine still enforces its own switches. "Run commands" being off comes back as its own refusal, in its
|
|
135
|
+
* own words, naming the control to flip — exactly as it does for the sandbox ops. */
|
|
136
|
+
export const MachineCommandSchema = z.enum(["mirror-off", "mirror-on"]);
|
|
137
|
+
export type MachineCommand = z.infer<typeof MachineCommandSchema>;
|
|
138
|
+
/* Which paired sandbox the command acts on: the machine's own id for it, as it appears in that machine's report,
|
|
139
|
+
* so nothing here has to re-derive the sanitizing the agent applied. Absent means every sandbox that machine
|
|
140
|
+
* pairs, which is what the CLI does when it is run bare.
|
|
141
|
+
*
|
|
142
|
+
* Pattern-bound because it becomes an argv token. It must start with an alphanumeric, not merely consist of id
|
|
143
|
+
* characters: a value like `--takeover` is made only of legal id characters and is a FLAG by the time the CLI on
|
|
144
|
+
* the machine parses it. Real ids are `sandbox-<hex>-<zone>`-shaped, so nothing legitimate leads with a dash. */
|
|
145
|
+
export const MachineSandboxIdSchema = z
|
|
146
|
+
.string()
|
|
147
|
+
.max(200)
|
|
148
|
+
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/);
|
|
149
|
+
export const MachineCommandInputSchema = z.object({
|
|
150
|
+
id: z.string().min(1),
|
|
151
|
+
command: MachineCommandSchema,
|
|
152
|
+
sandboxId: MachineSandboxIdSchema.optional(),
|
|
153
|
+
});
|
|
154
|
+
export type MachineCommandInput = z.infer<typeof MachineCommandInputSchema>;
|
|
155
|
+
/* What came back. `ok` is the command's own exit status, not this route's: a machine that refused the call, or a
|
|
156
|
+
* CLI that exited non-zero, is a real answer to show the person who clicked, not an exception to convert into
|
|
157
|
+
* one. Only an unreachable machine throws, because then there is nothing to report at all.
|
|
158
|
+
*
|
|
159
|
+
* `output` is what the command printed, kept because the CLI's own sentences ("Port mirroring OFF for: …") are
|
|
160
|
+
* better than anything this side would write over them. */
|
|
161
|
+
export const MachineCommandResultSchema = z.object({
|
|
162
|
+
ok: z.boolean(),
|
|
163
|
+
message: z.string(),
|
|
164
|
+
output: z.string().optional(),
|
|
165
|
+
});
|
|
166
|
+
export type MachineCommandResult = z.infer<typeof MachineCommandResultSchema>;
|
|
120
167
|
// One paired sandbox as the local agent holds it. `localDir` is the answer to the question the Desktop sync card
|
|
121
168
|
// has never been able to answer: which folder on that computer this sandbox's /work actually is.
|
|
122
169
|
export const MachinePairingSchema = z.object({
|
|
@@ -124,6 +171,19 @@ export const MachinePairingSchema = z.object({
|
|
|
124
171
|
mode: z.enum(["sync", "mirror"]),
|
|
125
172
|
// Set only for mode "sync", and only for the sandbox being reported to, see the redaction note above.
|
|
126
173
|
localDir: z.string().optional(),
|
|
174
|
+
/* Whether that computer is putting this sandbox's ports on its own localhost, which is a switch its owner
|
|
175
|
+
* holds and not a state this sandbox can read off anything else. An empty port list means two opposite
|
|
176
|
+
* things — nothing is listening in the sandbox, or the machine was told to keep them off — and only the
|
|
177
|
+
* second is worth a word on screen or a button to undo.
|
|
178
|
+
*
|
|
179
|
+
* The MACHINE owns the flag (the agent's `sync mirror off`), because the localhost being written to is
|
|
180
|
+
* there: a computer told to keep ports off must keep them off while this sandbox is asleep, unreachable, or
|
|
181
|
+
* arguing. A browser asks for it by running that same command over the machine's `host` capability, so the
|
|
182
|
+
* button and the CLI are one gesture rather than two mechanisms that can disagree.
|
|
183
|
+
*
|
|
184
|
+
* Optional because it is a fact only an agent new enough to have the switch reports; absent is read as "on",
|
|
185
|
+
* which is what mirroring has always been. */
|
|
186
|
+
mirroring: z.enum(["on", "off"]).optional(),
|
|
127
187
|
// Mutagen's own word for what the session is doing ("watching", "scanning", "transitioning", "halted-…").
|
|
128
188
|
// Carried verbatim rather than mapped to a traffic light: the halted states name their own cause, and a UI
|
|
129
189
|
// that reduces them to "problem" sends the user back to the terminal this report exists to replace.
|
|
@@ -315,8 +315,10 @@ export const ExtensionProcessParamSchema = z.object({
|
|
|
315
315
|
});
|
|
316
316
|
export const ExtensionProcessStatusSchema = z.object({
|
|
317
317
|
name: z.string().describe("Which process."),
|
|
318
|
-
running: z.boolean().describe("Whether it is up."),
|
|
318
|
+
running: z.boolean().describe("Whether it is up. False with a port means it crashed and the supervisor is waiting to retry it."),
|
|
319
319
|
port: z.number().optional().describe("The port it was given."),
|
|
320
|
+
restarts: z.number().optional().describe("How many times it died and was brought back since it was started. A growing number is a service in trouble."),
|
|
321
|
+
lastExitCode: z.number().optional().describe("How it last exited, when it has crashed at least once."),
|
|
320
322
|
previewUrl: z.string().optional().describe("Where to open it, when it has an address."),
|
|
321
323
|
});
|
|
322
324
|
export type ExtensionProcessStatus = z.infer<typeof ExtensionProcessStatusSchema>;
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// issues: inbound bug reports from the owner's own sites and apps, grouped by fingerprint
|
|
2
|
+
// (.intentic/records/issues/<fingerprint>.json, one file per group)
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { entryId } from "./internal.js";
|
|
5
|
+
|
|
6
|
+
/* WHAT A USER'S CRASH LOOKS LIKE BY THE TIME AN AGENT CAN ACT ON IT.
|
|
7
|
+
*
|
|
8
|
+
* Three shapes live here and the split between them is the whole design:
|
|
9
|
+
*
|
|
10
|
+
* IssueReportSchema what a stranger's browser POSTs to /intake/<id>/report. UNTRUSTED, every field of it,
|
|
11
|
+
* and bounded field by field because the endpoint is public and the body is a stack trace
|
|
12
|
+
* somebody else's machine wrote.
|
|
13
|
+
* IssueSchema what the daemon KEEPS: one group per fingerprint, with a count, a first/last seen and
|
|
14
|
+
* the most recent sample. A crash loop is one of these, not ten thousand.
|
|
15
|
+
* IssuesConfigSchema what the owner configures on the automation, and IssuePublicConfigSchema the subset the
|
|
16
|
+
* SDK is allowed to read back. Named field by field there, never by omission, the same
|
|
17
|
+
* rule WebchatConfig's public half is built on.
|
|
18
|
+
*
|
|
19
|
+
* The SDK imports these as TYPES ONLY (`import type`), so zod never reaches a visitor's browser, which is why
|
|
20
|
+
* the wire shapes live in this package beside the stored ones rather than being re-typed in the bundle. */
|
|
21
|
+
|
|
22
|
+
// What kind of thing arrived. `crash` and `detection` GROUP (one fingerprint, a rising count); `report` never
|
|
23
|
+
// does, because two people describing the same annoyance in their own words are two things to read.
|
|
24
|
+
export const IssueKindSchema = z.enum(["crash", "report", "detection"]);
|
|
25
|
+
export type IssueKind = z.infer<typeof IssueKindSchema>;
|
|
26
|
+
|
|
27
|
+
/* One thing that happened before the crash. Deliberately a flat {at, kind, message} rather than a per-source
|
|
28
|
+
* union: the value of a breadcrumb is being READ in order next to the others, and a shape the SDK can produce
|
|
29
|
+
* for a console line, a failed fetch and a route change alike is one the agent can render as a timeline
|
|
30
|
+
* without knowing which instrumentations the site switched on. */
|
|
31
|
+
export const IssueBreadcrumbSchema = z.object({
|
|
32
|
+
at: z.number().describe("When, in milliseconds."),
|
|
33
|
+
kind: z.string().max(40).describe("What sort of thing it was: a console line, a request, a click, a route change."),
|
|
34
|
+
message: z.string().max(300).describe("What it said, already truncated by the SDK."),
|
|
35
|
+
});
|
|
36
|
+
export type IssueBreadcrumb = z.infer<typeof IssueBreadcrumbSchema>;
|
|
37
|
+
|
|
38
|
+
// How much of the reporter we are willing to believe: nothing. Both fields are typed by whoever is reporting,
|
|
39
|
+
// so they reach the model beside the content rather than above it, exactly as a Front Desk visitor's
|
|
40
|
+
// `unverifiedDisplayName` does. There is no signed identity on this endpoint and there should not be one: a
|
|
41
|
+
// crash handler fires on a dying page, where no sign-in ceremony can run.
|
|
42
|
+
export const IssueReporterSchema = z.object({
|
|
43
|
+
email: z.string().max(320).optional().describe("An address they typed, to reach them about it. Unverified."),
|
|
44
|
+
name: z.string().max(200).optional().describe("A name they typed. Unverified, and never identity."),
|
|
45
|
+
});
|
|
46
|
+
export type IssueReporter = z.infer<typeof IssueReporterSchema>;
|
|
47
|
+
|
|
48
|
+
// A small bag of host-supplied strings (route, app version, locale, tenant). Bounded in both dimensions
|
|
49
|
+
// because it is the one open-ended field on a public endpoint, and an unbounded map is a storage bug waiting
|
|
50
|
+
// for the first person who loops over it.
|
|
51
|
+
const CONTEXT_KEYS_MAX = 20;
|
|
52
|
+
const IssueContextSchema = z
|
|
53
|
+
.record(z.string().max(60), z.string().max(300))
|
|
54
|
+
.refine((context) => Object.keys(context).length <= CONTEXT_KEYS_MAX, { message: `at most ${CONTEXT_KEYS_MAX} context entries` });
|
|
55
|
+
|
|
56
|
+
/* ONE REPORT, AS IT ARRIVES. Everything optional except `kind` and `message`, because the three kinds carry
|
|
57
|
+
* genuinely different evidence and a schema that demanded the union of them would refuse the commonest case:
|
|
58
|
+
* `window.onerror` in an old browser, which has a message and very little else. */
|
|
59
|
+
export const IssueReportSchema = z.object({
|
|
60
|
+
kind: IssueKindSchema.describe("A crash the SDK caught, something a person wrote in, or a problem the SDK noticed on its own."),
|
|
61
|
+
message: z.string().min(1).max(1000).describe("The error's own message, or the headline of what a person reported."),
|
|
62
|
+
stack: z.string().max(20_000).optional().describe("The stack, verbatim from the browser."),
|
|
63
|
+
url: z.string().max(2000).optional().describe("Where it happened: the page's address, or a screen name in an app."),
|
|
64
|
+
/* THE ONE FIELD THAT REPLACES AN ENTIRE INTEGRATION. The agent has the repository, so a build's sha or tag
|
|
65
|
+
* is enough to check that commit out and read the real frames; there is nothing to upload, no artifact
|
|
66
|
+
* store, and no sourcemap pipeline to keep in step with a deploy. A site that sets nothing here still gets
|
|
67
|
+
* a grouped, readable issue: it just costs the agent a guess about which build it came from. */
|
|
68
|
+
release: z.string().max(200).optional().describe("Which build it came from: a commit sha or a tag. With it the agent reads your real source rather than minified frames."),
|
|
69
|
+
userAgent: z.string().max(400).optional().describe("What the browser said it was."),
|
|
70
|
+
description: z.string().max(5000).optional().describe("What the person typed, when a person is the one reporting."),
|
|
71
|
+
reporter: IssueReporterSchema.optional().describe("Who says they are reporting it. Unverified by construction."),
|
|
72
|
+
breadcrumbs: z.array(IssueBreadcrumbSchema).max(40).optional().describe("What happened just before, oldest first."),
|
|
73
|
+
context: IssueContextSchema.optional().describe("Whatever else the app attached: a route, a version, a locale."),
|
|
74
|
+
/* The host's own grouping override, Sentry's convention and worth keeping: an app that knows two crashes
|
|
75
|
+
* are the same thing (or knows one crash is really two) can say so, and the daemon groups on this instead
|
|
76
|
+
* of on the stack. It is hashed like everything else, never used as a filename directly. */
|
|
77
|
+
fingerprint: z.string().max(200).optional().describe("Group by this instead of by the stack, when your app knows better than the stack does."),
|
|
78
|
+
});
|
|
79
|
+
export type IssueReport = z.infer<typeof IssueReportSchema>;
|
|
80
|
+
|
|
81
|
+
/* The body POSTed to /intake/<id>/report: the report plus the two things the GATE needs and the report itself
|
|
82
|
+
* has no business carrying, a client id to rate-limit against and the anti-bot answer. Kept out of
|
|
83
|
+
* IssueReportSchema so that what gets STORED (the sample) is the evidence and not the doorman's paperwork. */
|
|
84
|
+
export const IssueIngestSchema = z.object({
|
|
85
|
+
report: IssueReportSchema,
|
|
86
|
+
// The SDK's own per-browser id (localStorage). Not a secret and not identity, anyone can mint one: it is
|
|
87
|
+
// the key the per-minute limit counts against, so one runaway tab cannot spend the whole day's budget.
|
|
88
|
+
clientId: z.string().min(1).max(200).describe("The SDK's own id for this browser. Not a secret: it is what the rate limit counts against."),
|
|
89
|
+
// Only for `report`, and only when the automation asks for a proof of work: a crash fires on a dying page,
|
|
90
|
+
// where there is no second to spend on a puzzle and no user to wait for it.
|
|
91
|
+
powNonce: z.string().max(400).optional(),
|
|
92
|
+
// Minted by the owner, pasted into a mobile or server SDK that has no Origin header for the allowlist to
|
|
93
|
+
// read. An abuse LABEL rather than a secret (it ships inside a mobile binary and can be pulled out of one):
|
|
94
|
+
// the real ceilings are the dedup, the rate limit and the daily budget.
|
|
95
|
+
key: z.string().max(200).optional(),
|
|
96
|
+
});
|
|
97
|
+
export type IssueIngest = z.infer<typeof IssueIngestSchema>;
|
|
98
|
+
|
|
99
|
+
/* Where a group stands with the owner. `investigating` is set by the daemon when a turn is actually started
|
|
100
|
+
* for it (from a wake or from the Investigate button), never guessed: it is the difference between "nobody has
|
|
101
|
+
* looked at this" and "something is looking at it right now", which is the question a triage inbox is for. */
|
|
102
|
+
export const IssueStatusSchema = z.enum(["open", "investigating", "resolved", "ignored"]);
|
|
103
|
+
export type IssueStatus = z.infer<typeof IssueStatusSchema>;
|
|
104
|
+
|
|
105
|
+
// One agent turn started for this issue. The conversation id is the link: the fleet board already knows how to
|
|
106
|
+
// open one, so the inbox does not have to store anything about the run beyond which conversation it became.
|
|
107
|
+
export const IssueRunSchema = z.object({
|
|
108
|
+
conversationId: z.string().describe("The conversation this run became."),
|
|
109
|
+
at: z.number().describe("When it started, in milliseconds."),
|
|
110
|
+
// What the count stood at when this run was started, so a recurrence after a fix reads as "it came back",
|
|
111
|
+
// not as "someone already looked at this".
|
|
112
|
+
atCount: z.number().describe("How many times it had happened when this run started."),
|
|
113
|
+
});
|
|
114
|
+
export type IssueRun = z.infer<typeof IssueRunSchema>;
|
|
115
|
+
|
|
116
|
+
/* ONE GROUP. The id is the fingerprint and is the FILENAME, never in the body (json-dir.ts owns that rule), so
|
|
117
|
+
* a body that disagrees with its own grouping cannot be written. */
|
|
118
|
+
export const IssueSchema = z.object({
|
|
119
|
+
kind: IssueKindSchema,
|
|
120
|
+
// The one-line headline the inbox lists it under, derived from the report rather than typed, so two
|
|
121
|
+
// recurrences of one crash cannot be filed under two names.
|
|
122
|
+
title: z.string().min(1).max(300).describe("The one line this is listed under."),
|
|
123
|
+
culprit: z.string().max(300).optional().describe("The frame it came from, when the stack named one."),
|
|
124
|
+
// Which issues automation received it. A workspace can run several (one per site), and the inbox is one
|
|
125
|
+
// list across all of them, so the group has to say which door it came in through.
|
|
126
|
+
automationId: entryId.describe("Which intake received it."),
|
|
127
|
+
// The site or app it came from, as the browser's Origin or the SDK's declared one; absent for a keyed
|
|
128
|
+
// client (a mobile app, a server) that has no origin to send.
|
|
129
|
+
origin: z.string().max(400).optional().describe("Which site it came from."),
|
|
130
|
+
firstSeen: z.number().describe("When it first happened, in milliseconds."),
|
|
131
|
+
lastSeen: z.number().describe("When it last happened, in milliseconds."),
|
|
132
|
+
count: z.number().describe("How many times this exact thing has arrived."),
|
|
133
|
+
status: IssueStatusSchema.default("open").describe("Where it stands with you."),
|
|
134
|
+
statusAt: z.number().optional().describe("When the status last changed, in milliseconds."),
|
|
135
|
+
release: z.string().max(200).optional().describe("The build the latest one came from."),
|
|
136
|
+
// The most recent event in full. The LATEST rather than the first, deliberately: when a crash is still
|
|
137
|
+
// happening, what it looks like now is what a fix has to reproduce, and the first one is often from a
|
|
138
|
+
// build that no longer exists.
|
|
139
|
+
sample: IssueReportSchema.describe("The most recent one, in full."),
|
|
140
|
+
/* How many arrivals had been counted the last time this group WOKE an agent. The whole of the escalation
|
|
141
|
+
* rule lives in this one number: a group fires once when it is new, and again only when it has moved this
|
|
142
|
+
* far past its last firing. Stored rather than derived, because "how many since the last wake" is not
|
|
143
|
+
* recoverable from a count and a timestamp after a restart. */
|
|
144
|
+
firedAt: z.number().optional().describe("What the count stood at the last time this woke an agent."),
|
|
145
|
+
runs: z.array(IssueRunSchema).max(20).optional().describe("The turns started for it."),
|
|
146
|
+
});
|
|
147
|
+
export type Issue = z.infer<typeof IssueSchema>;
|
|
148
|
+
|
|
149
|
+
// The list row: the stored group plus its filename id (the fingerprint).
|
|
150
|
+
export const IssueSummarySchema = IssueSchema.extend({ id: entryId.describe("The issue's id, which is its fingerprint.") });
|
|
151
|
+
export type IssueSummary = z.infer<typeof IssueSummarySchema>;
|
|
152
|
+
|
|
153
|
+
// `invalid` is the same trust-boundary confession the drafts list makes, for the opposite reason: nothing but
|
|
154
|
+
// the daemon writes these, so a file in here that will not parse is a BUG in this daemon or a half-written
|
|
155
|
+
// volume, and either is worth seeing rather than silently skipping.
|
|
156
|
+
export const IssuesListSchema = z.object({
|
|
157
|
+
issues: z.array(IssueSummarySchema).describe("The inbox, most recently seen first."),
|
|
158
|
+
invalid: z.array(z.string()).describe("Files in the issues directory that could not be read at all."),
|
|
159
|
+
});
|
|
160
|
+
export type IssuesList = z.infer<typeof IssuesListSchema>;
|
|
161
|
+
|
|
162
|
+
export const IssueIdParamSchema = z.object({ id: entryId.describe("Which issue.") });
|
|
163
|
+
// Triage: the owner moving one row. `investigating` is not offered here, the daemon sets it when a turn
|
|
164
|
+
// actually starts, and letting a click claim it would make the one status that means something a lie.
|
|
165
|
+
export const IssueStatusInputSchema = z.object({
|
|
166
|
+
id: entryId.describe("Which issue."),
|
|
167
|
+
status: z.enum(["open", "resolved", "ignored"]).describe("Where it now stands with you."),
|
|
168
|
+
});
|
|
169
|
+
export type IssueStatusInput = z.infer<typeof IssueStatusInputSchema>;
|
|
170
|
+
|
|
171
|
+
/* ---- the automation's own settings ----
|
|
172
|
+
*
|
|
173
|
+
* Present only on `issues` listener automations, ignored on every other trigger, the shape WebchatConfig
|
|
174
|
+
* already established. `allowedOrigins` is NOT here: it lives on the trigger, because it is the admission gate
|
|
175
|
+
* the ingest route reads rather than a rendering choice, and one gate in two places is one gate. */
|
|
176
|
+
export const IssuesConfigSchema = z.object({
|
|
177
|
+
/* The key a client with no Origin presents (a mobile app, a server, a desktop build). Minted by the daemon
|
|
178
|
+
* on upsert like the event webhook's token, and for the same reason: every sender supports "paste this
|
|
179
|
+
* string", and nothing else is available to a caller with no identity and no browser.
|
|
180
|
+
*
|
|
181
|
+
* It is an abuse LABEL, not a secret, and the difference matters: it ships inside a binary anyone can pull
|
|
182
|
+
* apart. What it buys is that a leaked key can be rotated in one click while the origin allowlist keeps
|
|
183
|
+
* covering the web. The ceilings are what actually bound the damage. */
|
|
184
|
+
ingestKey: z.string().min(1).optional().describe("The key an app with no website origin presents. Rotate it freely: the limits, not this, are what bound the damage."),
|
|
185
|
+
/* Whether a browser with no allowed origin may still report by presenting the key. Off by default: the
|
|
186
|
+
* commonest way an intake gets abused is its key ending up in a public web bundle, and the allowlist is
|
|
187
|
+
* the thing that stops that mattering. */
|
|
188
|
+
keyFromBrowsers: z.boolean().optional().describe("Let a browser report with the key alone, rather than only from a site you listed. Off unless you need it."),
|
|
189
|
+
// The whole intake's ceiling per UTC day, counted in reports that reach the store. Absent ⇒
|
|
190
|
+
// ISSUES_DAILY_MAX_DEFAULT, never uncapped, for the reason the Front Desk's own daily cap gives.
|
|
191
|
+
dailyReportMax: z.number().int().positive().optional().describe("How many reports a day this intake accepts at all."),
|
|
192
|
+
/* HOW FAR A KNOWN CRASH HAS TO GO BEFORE IT INTERRUPTS ANYONE AGAIN. A group wakes an agent when it is
|
|
193
|
+
* new, and after that only when its count has grown by this much since the last wake. Absent ⇒
|
|
194
|
+
* ISSUES_ESCALATE_AFTER_DEFAULT.
|
|
195
|
+
*
|
|
196
|
+
* This is the number that makes the whole product safe to leave on. Without it a crash loop on one popular
|
|
197
|
+
* page is an agent turn per affected browser, which is a bill rather than a bug report. */
|
|
198
|
+
escalateAfter: z.number().int().positive().optional().describe("How many more times a known crash must happen before it wakes an agent again."),
|
|
199
|
+
// A proof-of-work puzzle on WRITTEN REPORTS only (a crash has no second to spend and no user to wait for
|
|
200
|
+
// it). Absent ⇒ off, leaving the origin allowlist and the ceilings as the whole boundary.
|
|
201
|
+
antiBot: z.enum(["pow"]).optional().describe("Make a person's browser solve a small puzzle before it accepts a written report."),
|
|
202
|
+
/* ---- the report dialog's chrome, all of it public by construction ---- */
|
|
203
|
+
title: z.string().max(80).optional().describe("The dialog's heading."),
|
|
204
|
+
prompt: z.string().max(300).optional().describe("The line above the box they type in."),
|
|
205
|
+
thanks: z.string().max(300).optional().describe("What it says once they have sent it."),
|
|
206
|
+
askEmail: z.boolean().optional().describe("Ask for an address to reply to. Optional for them either way."),
|
|
207
|
+
// A hex colour, for the reason WebchatConfig's `accent` is one: the SDK derives a hover and a focus ring
|
|
208
|
+
// from its channels rather than only painting it.
|
|
209
|
+
accent: z
|
|
210
|
+
.string()
|
|
211
|
+
.regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, "accent must be a hex colour, e.g. #e47100")
|
|
212
|
+
.optional(),
|
|
213
|
+
// Whether the SDK arms window.onerror / unhandledrejection at all. Absent ⇒ on: a site that embedded a
|
|
214
|
+
// crash reporter meant to report crashes.
|
|
215
|
+
captureCrashes: z.boolean().optional().describe("Catch uncaught errors automatically, as well as what people write in."),
|
|
216
|
+
});
|
|
217
|
+
export type IssuesConfig = z.infer<typeof IssuesConfigSchema>;
|
|
218
|
+
|
|
219
|
+
/* What the SDK is told about itself, fully RESOLVED daemon-side so the bundle carries no fallback logic. Every
|
|
220
|
+
* field is named here rather than spread from the config: a secret added to IssuesConfig later is invisible to
|
|
221
|
+
* a stranger's browser until somebody deliberately lists it, which is the property this shape exists for.
|
|
222
|
+
* `ingestKey` is conspicuously absent, a browser proves itself by its origin. */
|
|
223
|
+
export const IssuePublicConfigSchema = z.object({
|
|
224
|
+
automationId: z.string(),
|
|
225
|
+
title: z.string(),
|
|
226
|
+
prompt: z.string(),
|
|
227
|
+
thanks: z.string(),
|
|
228
|
+
askEmail: z.boolean(),
|
|
229
|
+
accent: z.string(),
|
|
230
|
+
captureCrashes: z.boolean(),
|
|
231
|
+
// "off" spelled out rather than left absent, for the reason the Front Desk's is: the SDK branches on it,
|
|
232
|
+
// and a missing field meaning "no challenge" is how one serialization bug becomes an open door.
|
|
233
|
+
antiBot: z.enum(["pow", "off"]),
|
|
234
|
+
});
|
|
235
|
+
export type IssuePublicConfig = z.infer<typeof IssuePublicConfigSchema>;
|
|
236
|
+
|
|
237
|
+
// The proof-of-work challenge, the same shape and the same solver as the Front Desk's.
|
|
238
|
+
export const IssueChallengeSchema = z.object({ salt: z.string(), difficulty: z.number().int().positive() });
|
|
239
|
+
export type IssueChallenge = z.infer<typeof IssueChallengeSchema>;
|
|
240
|
+
|
|
241
|
+
/* What the intake answers with. A short reference the reporter can be shown ("we filed this as 4f3a…"), and
|
|
242
|
+
* nothing else: whether this crash is new, how often it has happened and whether it woke anybody are the
|
|
243
|
+
* owner's facts, and the caller is a stranger's browser. */
|
|
244
|
+
export const IssueAcceptedSchema = z.object({ ok: z.literal(true), id: z.string() });
|
|
245
|
+
export type IssueAccepted = z.infer<typeof IssueAcceptedSchema>;
|
|
246
|
+
|
|
247
|
+
/* Which origins have loaded this intake's SDK, and which were turned away. The Front Desk's install probe,
|
|
248
|
+
* whole, because the setup mistake is identical and so is the silence it produces: a snippet that was never
|
|
249
|
+
* pasted, and one pasted on an origin the allowlist does not have, are both "an intake with no reports". */
|
|
250
|
+
export const IssueInstallSchema = z.object({
|
|
251
|
+
origin: z.string(),
|
|
252
|
+
allowed: z.boolean(),
|
|
253
|
+
lastSeenAt: z.number(),
|
|
254
|
+
loads: z.number(),
|
|
255
|
+
});
|
|
256
|
+
export const IssueInstallsSchema = z.object({ origins: z.array(IssueInstallSchema) });
|
|
257
|
+
export type IssueInstalls = z.infer<typeof IssueInstallsSchema>;
|
|
258
|
+
export const IssueIntakeIdParamSchema = z.object({ automationId: entryId.describe("Which intake.") });
|
|
259
|
+
|
|
260
|
+
/* ---- the numbers both ends need ----
|
|
261
|
+
*
|
|
262
|
+
* Here rather than beside the route that enforces them, for WebchatConfig's reason: the automation editor has
|
|
263
|
+
* to be able to show the owner what they are already protected by, and a limit that is invisible until it is
|
|
264
|
+
* hit gets filed as a bug. */
|
|
265
|
+
|
|
266
|
+
/* The daily ceiling an intake gets when its owner sets none. Larger than the Front Desk's 200 because the unit
|
|
267
|
+
* is different: a report is a file write and only SOMETIMES an agent turn (dedup decides), where a visitor
|
|
268
|
+
* message is always a turn. High enough that a real product's bad afternoon fits inside it, low enough that a
|
|
269
|
+
* script pointed at the endpoint stops being interesting within seconds. */
|
|
270
|
+
export const ISSUES_DAILY_MAX_DEFAULT = 2000;
|
|
271
|
+
|
|
272
|
+
/* How much a known crash has to grow before it wakes anybody again. Ten is chosen to be quiet on the tail (one
|
|
273
|
+
* more person hitting a known bug is not news) and prompt on a spike: a regression that starts affecting
|
|
274
|
+
* everybody crosses it almost at once, which is exactly when the second wake is worth having. */
|
|
275
|
+
export const ISSUES_ESCALATE_AFTER_DEFAULT = 10;
|
|
276
|
+
|
|
277
|
+
// How much of one ingest body the daemon will read. A stack plus forty breadcrumbs plus a description is a few
|
|
278
|
+
// kilobytes; this leaves room for a pathological framework trace without letting the endpoint be a file upload.
|
|
279
|
+
export const ISSUE_PAYLOAD_MAX = 96_000;
|
|
@@ -13,7 +13,7 @@ import { WorkspaceHotspotSchema, WorkspaceKeyModuleSchema } from "./codebase-hea
|
|
|
13
13
|
* signals things the daemon already knows, the resident iq index's health ranking, the package manifests it
|
|
14
14
|
* reads for the dependency graph, its own node version. Recomputed per request; all of it is cheap. */
|
|
15
15
|
|
|
16
|
-
export const PROBE_IDS = ["outdated", "audit", "knip", "jscpd", "ui", "bundle"] as const;
|
|
16
|
+
export const PROBE_IDS = ["outdated", "audit", "knip", "jscpd", "ui", "bundle", "mutation"] as const;
|
|
17
17
|
export const ProbeIdSchema = z.enum(PROBE_IDS);
|
|
18
18
|
export type ProbeId = z.infer<typeof ProbeIdSchema>;
|
|
19
19
|
// One dependency the registry has moved past. `kind` is the SEMVER distance, which is the whole reason this is
|
|
@@ -165,6 +165,55 @@ export const BundleSchema = z.object({
|
|
|
165
165
|
.describe("What is in it, piece by piece."),
|
|
166
166
|
});
|
|
167
167
|
export type Bundle = z.infer<typeof BundleSchema>;
|
|
168
|
+
/* WHAT THE SUITE WOULD NOTICE IF THE CODE BROKE. Coverage says a line ran; this says an assertion depended on it.
|
|
169
|
+
*
|
|
170
|
+
* The distinction is the whole reason this probe exists, and it is not theoretical here. Measured on
|
|
171
|
+
* sandbox-contract's chore module — 109 hand-written tests, every line covered — 16 of 58 mutants survived, and
|
|
172
|
+
* one of them flips the zero boundary in `bucketOf` that digest.ts's own comment argues is load-bearing. The test
|
|
173
|
+
* that was supposed to hold it (`expect(bucketOf(0)).not.toBe(bucketOf(1))`) is written in the careful,
|
|
174
|
+
* deliberately un-brittle style, and that is exactly why it cannot see the change: with the boundary moved the two
|
|
175
|
+
* values are still different, so the assertion still passes.
|
|
176
|
+
*
|
|
177
|
+
* That is the failure this measures and nothing else in the repository can. A linter sees the assertion's shape,
|
|
178
|
+
* not its power; a coverage report sees the line, not whether anything checked it. Only killing the code and
|
|
179
|
+
* watching what the suite says distinguishes a test from a test-shaped thing.
|
|
180
|
+
*
|
|
181
|
+
* SURVIVORS, NOT JUST A SCORE. A percentage is a mood; `bucketOf: count <= 0 → count < 0 survives` is a morning's
|
|
182
|
+
* work with the answer already in it. The score decides whether the chore speaks, the survivors are what makes it
|
|
183
|
+
* worth speaking about. */
|
|
184
|
+
export const MutationScoreSchema = z.object({
|
|
185
|
+
score: z
|
|
186
|
+
.number()
|
|
187
|
+
.describe("The share of injected faults the suite caught. Not a coverage figure: coverage says a line ran, this says an assertion depended on it."),
|
|
188
|
+
killed: z.number().int().nonnegative().describe("Faults the suite caught."),
|
|
189
|
+
survived: z.number().int().nonnegative().describe("Faults it did not: code that can be broken with every test still green."),
|
|
190
|
+
/* Mutants that never got a verdict: ones that would not compile, and ones the configuration ignored. Kept
|
|
191
|
+
* apart from both counts above, and OUT of the score, because an unmeasured mutant is not evidence either
|
|
192
|
+
* way — the same conflation `unavailable` exists to prevent one level up.
|
|
193
|
+
*
|
|
194
|
+
* A timeout is deliberately NOT here. Stryker counts it as detected, on the reasoning that a mutant which
|
|
195
|
+
* hangs the suite is one the suite noticed, and this follows Stryker's arithmetic rather than inventing a
|
|
196
|
+
* second definition of the same word: the number on the row has to mean what the tool that produced it
|
|
197
|
+
* means, or the row is quietly arguing with its own evidence. */
|
|
198
|
+
inconclusive: z
|
|
199
|
+
.number()
|
|
200
|
+
.int()
|
|
201
|
+
.nonnegative()
|
|
202
|
+
.describe("Faults it never got a verdict on, because they would not compile or were configured out. Left out of the score entirely, since neither answer is known."),
|
|
203
|
+
/* The worst offenders, named. Capped, and the cap is the point: a survivor list is only useful while it is
|
|
204
|
+
* short enough to act on, and the rest are still there on the next run. */
|
|
205
|
+
survivors: z
|
|
206
|
+
.array(
|
|
207
|
+
z.object({
|
|
208
|
+
file: z.string().describe("Where it is."),
|
|
209
|
+
line: z.number().int().nonnegative().describe("Which line."),
|
|
210
|
+
mutator: z.string().describe("What was changed, in the mutation tool's own vocabulary."),
|
|
211
|
+
replacement: z.string().describe("What it became, so a reader can judge whether it matters without opening the file."),
|
|
212
|
+
}),
|
|
213
|
+
)
|
|
214
|
+
.describe("The surviving faults themselves. A percentage is a mood; a named line with the change that went unnoticed is a morning's work."),
|
|
215
|
+
});
|
|
216
|
+
export type MutationScore = z.infer<typeof MutationScoreSchema>;
|
|
168
217
|
/* One probe's cached result. The three states are deliberately distinct, because a panel that collapses them
|
|
169
218
|
* lies about the most important case:
|
|
170
219
|
* ok the tool ran and reported. `facts` carries its findings, including "nothing found", which is
|
|
@@ -187,6 +236,7 @@ export const ProbeFactsSchema = z.discriminatedUnion("id", [
|
|
|
187
236
|
z.object({ id: z.literal("jscpd"), duplication: DuplicationSchema }),
|
|
188
237
|
z.object({ id: z.literal("ui"), scan: UiScanSchema }),
|
|
189
238
|
z.object({ id: z.literal("bundle"), bundle: BundleSchema }),
|
|
239
|
+
z.object({ id: z.literal("mutation"), mutation: MutationScoreSchema }),
|
|
190
240
|
]);
|
|
191
241
|
export type ProbeFacts = z.infer<typeof ProbeFactsSchema>;
|
|
192
242
|
export const ProbeResultSchema = z.object({
|
package/src/schemas/settings.ts
CHANGED
|
@@ -422,6 +422,38 @@ export const SandboxSettingsSchema = z.object({
|
|
|
422
422
|
.describe(
|
|
423
423
|
"Whether a version the agent is about to pin is checked against the package's own registry first. Facts only, or facts plus the name of a maintained replacement where the registry agrees the current choice has been abandoned. It tells the agent and lets it decide rather than refusing, because matching a version your project already uses is usually the right answer and a gate would fight it.",
|
|
424
424
|
),
|
|
425
|
+
/* DOES THE TEST IT JUST WROTE ACTUALLY TEST THE CHANGE?
|
|
426
|
+
*
|
|
427
|
+
* A model writes a test that passes, and a passing test is the thing it was asked for, so nothing in the loop
|
|
428
|
+
* objects. The failure this catches is the one where the test would have passed BEFORE the change too: it
|
|
429
|
+
* exercises the code without depending on the behaviour that was added, and it will keep passing when that
|
|
430
|
+
* behaviour is broken later. It type-checks, it lints, `pnpm verify` is green, and the suite has grown a test
|
|
431
|
+
* that can never fail.
|
|
432
|
+
*
|
|
433
|
+
* The check is a differential, and it is cheap for one specific reason: in an agent turn we already know
|
|
434
|
+
* exactly which source files changed. So the test is re-run with those files' HEAD contents served in place
|
|
435
|
+
* of the working copies, and the answer is one bit — did it still pass? A test that passes against the old
|
|
436
|
+
* code did not test the new code. This is one-mutant mutation testing where the mutant is "your change,
|
|
437
|
+
* reverted", which is the cheapest useful mutant there is and the only one already known for free.
|
|
438
|
+
*
|
|
439
|
+
* NOTHING IS WRITTEN TO THE TREE. The HEAD copies are served through a vite `load` hook in a generated config,
|
|
440
|
+
* never checked out over the working files — a hook that could leave a half-reverted tree behind on a crash
|
|
441
|
+
* would be trading a whole turn's work for a lint-grade signal.
|
|
442
|
+
*
|
|
443
|
+
* IT REPORTS AND GETS OUT OF THE WAY, for the same reason the freshness check above does. Two legitimate
|
|
444
|
+
* cases pass on HEAD and must not be fought: a test written BEFORE its implementation (it fails now, which
|
|
445
|
+
* the agent can see without help), and a pure refactor, where a test that keeps passing is the entire point.
|
|
446
|
+
* Distinguishing those from a weak test needs intent, so the fact rides back as context and the model decides.
|
|
447
|
+
*
|
|
448
|
+
* Scoped to source changed in the SAME package as the test, because that is what a package's own vitest run
|
|
449
|
+
* loads from source; a cross-package import resolves to the other package's built output, where there is
|
|
450
|
+
* nothing to swap. Off by default like every flag here, and off wires no hook at all. */
|
|
451
|
+
testFaultDetection: z
|
|
452
|
+
.boolean()
|
|
453
|
+
.default(false)
|
|
454
|
+
.describe(
|
|
455
|
+
"After the agent writes a test, re-run it against the code as it was before this turn's changes. A test that still passes did not test the change — it will pass just as happily when that behaviour breaks. Reports what it finds and lets the agent decide, because a test written before its implementation and a pure refactor both pass this honestly.",
|
|
456
|
+
),
|
|
425
457
|
outputCleaners: z
|
|
426
458
|
.string()
|
|
427
459
|
.default("")
|
package/src/schemas/terminal.ts
CHANGED
|
@@ -10,10 +10,12 @@ import { z } from "zod";
|
|
|
10
10
|
// Bash commands run in (live-watchable, AI-marked in the UI; running:false once every window is a finished
|
|
11
11
|
// command's dead pane, which is what lets the panel sweep it), `job` = a job-* session the daemon's terminal
|
|
12
12
|
// runner executes user-triggered flows in (capability adds, infra check), `process` = a managed background
|
|
13
|
-
// process
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
13
|
+
// process: a supervised daemon child for an extension's declared processes (name `svc-<key>`, whose
|
|
14
|
+
// "terminal" is a tail of the service's log file), or a tmux-riding one for dockerd and the local model
|
|
15
|
+
// servers, which outlive daemon restarts on purpose. Surfaced in the panel's background-processes popover
|
|
16
|
+
// with read-only log views, never as a killable tab; running is the actual process. A process row that maps
|
|
17
|
+
// to an installed extension's declared process carries extensionId+processName, the address for its
|
|
18
|
+
// /extensions start/stop routes. The
|
|
17
19
|
// `{name}` kill-route param is a bare string validated in the handler (a bad name is a BAD_REQUEST) since the
|
|
18
20
|
// same charset gates a `tmux kill-session -t` shell-out. The agent's BROWSER is deliberately NOT one of these
|
|
19
21
|
// kinds: a Chromium with its own tab strip is a surface in its own right, not a pane in the terminal panel, so
|
package/src/schemas/webext.ts
CHANGED
|
@@ -77,3 +77,32 @@ export const WebExtSessionImportSchema = z.object({
|
|
|
77
77
|
cookies: z.array(WebExtCookieSchema).min(1).max(300),
|
|
78
78
|
});
|
|
79
79
|
export type WebExtSessionImport = z.infer<typeof WebExtSessionImportSchema>;
|
|
80
|
+
|
|
81
|
+
/* ---- and the same door in the other direction: LENDING a sandbox session to the person's own browser ----
|
|
82
|
+
*
|
|
83
|
+
* The case this answers is the one no amount of streaming quality can: a passkey bound to an authenticator the
|
|
84
|
+
* person physically holds, a hardware second factor that has to be touched, an employer's SSO that checks the
|
|
85
|
+
* device. Driving the sandbox's browser remotely is not a worse experience on those sites, it is an impossible
|
|
86
|
+
* one — so the session goes to the browser the account was actually enrolled on, the person finishes the step
|
|
87
|
+
* as themselves, and `connect_site` hands it back.
|
|
88
|
+
*
|
|
89
|
+
* Same door and same rule as the import above: the cookies travel on the extension's own HTTPS request, never
|
|
90
|
+
* as a socket answer, because socket answers are MCP results and MCP results land in the model's context. */
|
|
91
|
+
export const WebExtSessionExportSchema = z.object({
|
|
92
|
+
// The `browser`-kind capability to lend FROM. Named by the agent out of the roster it can already read.
|
|
93
|
+
account: z.string().min(1),
|
|
94
|
+
// The registrable domain to lend, and the reason this cannot lend a whole profile: a profile holds every
|
|
95
|
+
// account its owner ever connected, and handing all of it over because one site got stuck is the kind of
|
|
96
|
+
// over-broad grant that is invisible until it matters.
|
|
97
|
+
domain: z.string().min(1),
|
|
98
|
+
});
|
|
99
|
+
export type WebExtSessionExport = z.infer<typeof WebExtSessionExportSchema>;
|
|
100
|
+
|
|
101
|
+
export const WebExtSessionExportResultSchema = z.object({
|
|
102
|
+
ok: z.boolean(),
|
|
103
|
+
// What the owner reads and the agent reads back. Never carries a cookie name or a value.
|
|
104
|
+
message: z.string(),
|
|
105
|
+
// Only ever read by the extension, which writes it straight into this browser's own cookie store.
|
|
106
|
+
cookies: z.array(WebExtCookieSchema).optional(),
|
|
107
|
+
});
|
|
108
|
+
export type WebExtSessionExportResult = z.infer<typeof WebExtSessionExportResultSchema>;
|
package/src/tunnel-ids.test.ts
CHANGED
|
@@ -42,7 +42,9 @@ test("slots are not derivable from the sandbox id: only from the token behind it
|
|
|
42
42
|
expect(portSlotsFromToken(OTHER)).not.toEqual(portSlotsFromToken(TOKEN));
|
|
43
43
|
// No slot leaks the id it will be paired with in `port-<slot>-<sandboxId>`.
|
|
44
44
|
const id = sandboxIdFromToken(TOKEN);
|
|
45
|
-
|
|
45
|
+
// Load-bearing, and not merely tidy: `not.toContain(undefined)` is true of every slot list ever produced,
|
|
46
|
+
// so without a real id the leak check below would pass on a function that had stopped deriving one at all.
|
|
47
|
+
expect(id).toEqual(expect.any(String));
|
|
46
48
|
expect(portSlotsFromToken(TOKEN)).not.toContain(id);
|
|
47
49
|
// And none of them is the old fixed alphabet, which is what made the hostnames guessable.
|
|
48
50
|
expect(portSlotsFromToken(TOKEN).some((slot) => slot.length === 1)).toBe(false);
|
package/src/webext-links.ts
CHANGED
|
@@ -23,6 +23,16 @@ export const webextConnectUrl = (sandboxUrl: string): string => `${sandboxUrl.re
|
|
|
23
23
|
* and the one thing that would be catastrophic to leak is the one thing the model never sees. */
|
|
24
24
|
export const webextSessionUrl = (sandboxUrl: string): string => `${sandboxUrl.replace(/\/$/, "")}/system/webext/session`;
|
|
25
25
|
|
|
26
|
+
/* The same door in the other direction: where the extension asks for a sandbox account's session to be LENT to
|
|
27
|
+
* this browser, so the owner can finish a step no remote browser can do — a passkey, a hardware second factor,
|
|
28
|
+
* an employer's SSO that checks the device.
|
|
29
|
+
*
|
|
30
|
+
* A separate path rather than a verb on the one above, because the two are different acts with different
|
|
31
|
+
* refusals ("no such account" versus "that account is not signed in there") and a single endpoint doing both
|
|
32
|
+
* would have to be read twice to know which. Same bearer, and the same rule about the socket: the cookies come
|
|
33
|
+
* back on THIS request, never as an MCP result. */
|
|
34
|
+
export const webextLendUrl = (sandboxUrl: string): string => `${sandboxUrl.replace(/\/$/, "")}/system/webext/lend`;
|
|
35
|
+
|
|
26
36
|
/* ---- the pairing code: the one string that travels from the sandbox's card into the extension ----
|
|
27
37
|
*
|
|
28
38
|
* A connected computer is paired by a shell one-liner, which can carry two values in two environment variables
|
|
@@ -162,7 +162,9 @@ describe(`WORKSPACE_STATE_FILES`, () => {
|
|
|
162
162
|
// the difference visible at review time.
|
|
163
163
|
for (const file of WORKSPACE_STATE_FILES) {
|
|
164
164
|
if (file.invalidates.length === 0) {
|
|
165
|
-
|
|
165
|
+
// A string with something in it. Truthiness would accept the number 1 or an object, and the
|
|
166
|
+
// failure this guards against — a `why` left blank to quiet the check — reads as whitespace.
|
|
167
|
+
expect(file.why, `${file.path} invalidates nothing and must say why`).toEqual(expect.stringMatching(/\S/));
|
|
166
168
|
} else {
|
|
167
169
|
expect(file.why, `${file.path} invalidates queries, so \`why\` is dead weight`).toBeUndefined();
|
|
168
170
|
}
|