@intentic/sandbox-contract 1.233.0 → 1.234.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +4 -3
  2. package/dist/agent-catalog.d.ts +2 -1
  3. package/dist/agent-catalog.d.ts.map +1 -1
  4. package/dist/agent-catalog.js +19 -13
  5. package/dist/agent-catalog.js.map +1 -1
  6. package/dist/chores/chores.d.ts.map +1 -1
  7. package/dist/chores/chores.js +36 -1
  8. package/dist/chores/chores.js.map +1 -1
  9. package/dist/chores/probes.d.ts.map +1 -1
  10. package/dist/chores/probes.js +70 -0
  11. package/dist/chores/probes.js.map +1 -1
  12. package/dist/command-classes.d.ts +5 -2
  13. package/dist/command-classes.d.ts.map +1 -1
  14. package/dist/command-classes.js +34 -13
  15. package/dist/command-classes.js.map +1 -1
  16. package/dist/contracts/automations.contract.d.ts +30 -0
  17. package/dist/contracts/automations.contract.d.ts.map +1 -1
  18. package/dist/contracts/chores.contract.d.ts +17 -0
  19. package/dist/contracts/chores.contract.d.ts.map +1 -1
  20. package/dist/contracts/extensions.contract.d.ts +2 -0
  21. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  22. package/dist/contracts/extensions.contract.js.map +1 -1
  23. package/dist/contracts/issues.contract.d.ts +89 -0
  24. package/dist/contracts/issues.contract.d.ts.map +1 -0
  25. package/dist/contracts/issues.contract.js +50 -0
  26. package/dist/contracts/issues.contract.js.map +1 -0
  27. package/dist/contracts/runner.contract.d.ts +102 -102
  28. package/dist/contracts/settings.contract.d.ts +12 -0
  29. package/dist/contracts/settings.contract.d.ts.map +1 -1
  30. package/dist/credential-material.d.ts +2 -0
  31. package/dist/credential-material.d.ts.map +1 -0
  32. package/dist/credential-material.js +36 -0
  33. package/dist/credential-material.js.map +1 -0
  34. package/dist/definition.d.ts +8 -0
  35. package/dist/definition.d.ts.map +1 -1
  36. package/dist/history-state.d.ts.map +1 -1
  37. package/dist/history-state.js +1 -0
  38. package/dist/history-state.js.map +1 -1
  39. package/dist/hostnames.d.ts +2 -0
  40. package/dist/hostnames.d.ts.map +1 -1
  41. package/dist/hostnames.js +3 -1
  42. package/dist/hostnames.js.map +1 -1
  43. package/dist/index.d.ts +268 -116
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +5 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/runtime-state.d.ts +9 -0
  48. package/dist/runtime-state.d.ts.map +1 -1
  49. package/dist/runtime-state.js +3 -0
  50. package/dist/runtime-state.js.map +1 -1
  51. package/dist/schemas/agent.d.ts +6 -0
  52. package/dist/schemas/agent.d.ts.map +1 -1
  53. package/dist/schemas/agent.js +2 -1
  54. package/dist/schemas/agent.js.map +1 -1
  55. package/dist/schemas/automations.d.ts +45 -0
  56. package/dist/schemas/automations.d.ts.map +1 -1
  57. package/dist/schemas/automations.js +6 -1
  58. package/dist/schemas/automations.js.map +1 -1
  59. package/dist/schemas/extension-updates.d.ts +2 -0
  60. package/dist/schemas/extension-updates.d.ts.map +1 -1
  61. package/dist/schemas/extension-updates.js +3 -1
  62. package/dist/schemas/extension-updates.js.map +1 -1
  63. package/dist/schemas/issues.d.ts +324 -0
  64. package/dist/schemas/issues.d.ts.map +1 -0
  65. package/dist/schemas/issues.js +107 -0
  66. package/dist/schemas/issues.js.map +1 -0
  67. package/dist/schemas/maintenance.d.ts +62 -1
  68. package/dist/schemas/maintenance.d.ts.map +1 -1
  69. package/dist/schemas/maintenance.js +22 -1
  70. package/dist/schemas/maintenance.js.map +1 -1
  71. package/dist/schemas/settings.d.ts +6 -0
  72. package/dist/schemas/settings.d.ts.map +1 -1
  73. package/dist/schemas/settings.js +4 -0
  74. package/dist/schemas/settings.js.map +1 -1
  75. package/dist/schemas/terminal.d.ts.map +1 -1
  76. package/dist/schemas/terminal.js.map +1 -1
  77. package/dist/schemas/webext.d.ts +24 -0
  78. package/dist/schemas/webext.d.ts.map +1 -1
  79. package/dist/schemas/webext.js +9 -0
  80. package/dist/schemas/webext.js.map +1 -1
  81. package/dist/webext-links.d.ts +1 -0
  82. package/dist/webext-links.d.ts.map +1 -1
  83. package/dist/webext-links.js +1 -0
  84. package/dist/webext-links.js.map +1 -1
  85. package/dist/workspace-state.d.ts +10 -6
  86. package/dist/workspace-state.d.ts.map +1 -1
  87. package/dist/workspace-state.js +13 -8
  88. package/dist/workspace-state.js.map +1 -1
  89. package/package.json +8 -8
  90. package/src/agent-catalog.test.ts +55 -54
  91. package/src/agent-catalog.ts +29 -22
  92. package/src/capability-ledger.test.ts +11 -2
  93. package/src/chores/chores.test.ts +1 -1
  94. package/src/chores/chores.ts +66 -1
  95. package/src/chores/probes.test.ts +65 -0
  96. package/src/chores/probes.ts +133 -1
  97. package/src/chores/verdict.test.ts +29 -19
  98. package/src/command-classes.test.ts +71 -4
  99. package/src/command-classes.ts +112 -22
  100. package/src/contracts/extensions.contract.ts +3 -2
  101. package/src/contracts/issues.contract.ts +60 -0
  102. package/src/credential-material.test.ts +120 -0
  103. package/src/credential-material.ts +100 -0
  104. package/src/history-state.ts +12 -0
  105. package/src/hostnames.ts +18 -2
  106. package/src/index.ts +5 -0
  107. package/src/routes.test.ts +4 -2
  108. package/src/runtime-state.ts +16 -0
  109. package/src/schemas/agent.ts +15 -2
  110. package/src/schemas/automations.ts +14 -2
  111. package/src/schemas/extension-updates.ts +3 -1
  112. package/src/schemas/issues.ts +279 -0
  113. package/src/schemas/maintenance.ts +51 -1
  114. package/src/schemas/settings.ts +32 -0
  115. package/src/schemas/terminal.ts +6 -4
  116. package/src/schemas/webext.ts +29 -0
  117. package/src/tunnel-ids.test.ts +3 -1
  118. package/src/webext-links.ts +10 -0
  119. package/src/workspace-state.test.ts +3 -1
  120. package/src/workspace-state.ts +27 -37
@@ -0,0 +1,100 @@
1
+ /* WHETHER A FILE ACTUALLY HOLDS A CREDENTIAL, the fact that `secrets.access` only guesses at from a path.
2
+ *
3
+ * The classifier next door (command-classes.ts) reads shell text, so the strongest thing it can honestly say
4
+ * about `sed … ~/.npmrc` is "this names a file that USUALLY holds a token". Usually is not always, and the gap
5
+ * is where the class earns its reputation: most `~/.npmrc` files are three lines of registry config, most
6
+ * `.env` files in a monorepo are ports and feature flags, `~/.ssh/known_hosts` is a list of public keys, and a
7
+ * file that does not exist reads nothing at all. A card raised over one of those is not a near miss, it is
8
+ * noise, and noise is what teaches an owner to answer the card without reading it, which is precisely how a
9
+ * real credential read gets waved through six weeks later.
10
+ *
11
+ * So the class is split in two. The path table says WHICH FILES ARE WORTH LOOKING AT; this says WHAT A
12
+ * CREDENTIAL LOOKS LIKE once one has been opened. It lives here, beside that table, because the two halves are
13
+ * one definition of the class and an enforcement point that has a filesystem should not have to invent the
14
+ * second half for itself (guard/credential-files.ts is the sandbox's fs half; a caller with no filesystem, the
15
+ * browser or the machine agent, simply never asks and the path stands on its name alone).
16
+ *
17
+ * PURE, over the file's own text: no fs, no path logic, nothing to configure. That keeps this package free of
18
+ * a runtime the editor bundle cannot have, and keeps the rule testable as a table of strings.
19
+ *
20
+ * IT ANSWERS THE EASY DIRECTION WELL AND THE HARD ONE CONSERVATIVELY. Everything below is written to say YES
21
+ * on anything credential-shaped, because a yes costs one card and a no costs the whole rule: the only use of
22
+ * this answer is to REMOVE a class the path table already added, so a false yes leaves behavior exactly as it
23
+ * was and a false no silently un-gates a real read.
24
+ */
25
+
26
+ /* A KEY THAT NAMES A CREDENTIAL, AND ITS VALUE. Matched as a suffix of whatever the key is spelled as, because
27
+ * these files say the same thing a dozen ways and only the last word carries the meaning: `NPM_TOKEN`,
28
+ * `//registry.npmjs.org/:_authToken`, `"accessToken"`, `aws_secret_access_key`, `password =`.
29
+ *
30
+ * The three value arms are the three ways a value is quoted across the formats this ever sees: JSON's double
31
+ * quotes, a shell-ish dotenv's single quotes, and an ini file's bare rest-of-line.
32
+ *
33
+ * THE SEPARATOR MAY NOT CROSS A LINE, which is the difference between reading a file and reading a soup: with
34
+ * `\s*` around it, an empty `GITHUB_TOKEN=` borrows the NEXT line as its value, and a dotenv of nine blank
35
+ * placeholders reads as eight credentials. Every format here puts a value on its key's own line. */
36
+ const CREDENTIAL_ASSIGNMENT =
37
+ /(?:auth[_-]?token|access[_-]?token|refresh[_-]?token|api[_-]?key|access[_-]?key|secret[_-]?key|client[_-]?secret|private[_-]?key|passwo?rd|passphrase|credentials?|secret|token|bearer)["']?[ \t]*[:=][ \t]*(?:"([^"\n]*)"|'([^'\n]*)'|([^\s"',;}\n]*))/gi;
38
+
39
+ /* A VALUE THAT IS NOT A CREDENTIAL EVEN THOUGH ITS KEY SAYS IT IS: the empty one, the one still holding the
40
+ * template's own words, and the one deferring to an environment variable or a secret reference. Every dotenv
41
+ * that ships in a repo is made of these, and firing on `GITHUB_TOKEN=${GITHUB_TOKEN}` would put a card in front
42
+ * of reading a file whose entire content is the absence of a secret. */
43
+ const PLACEHOLDER =
44
+ /^(?:\$\{?[\w:.-]+\}?|\{\{[^}]*\}\}|<[^>]*>|%\w+%|x{3,}|\*{3,}|\.{3,}|…|(?:your|my|our|the)[-_\s].*|change[-_]?me|replace[-_]?(?:me|this|with)|todo|tbd|fixme|none|null|nil|undefined|true|false|example|placeholder|redacted|dummy|sample|test|fake|secret|password|token|value|here)$/i;
45
+
46
+ /* Shorter than any credential a service actually issues. It clears the dev-compose defaults an agent reads all
47
+ * day (`POSTGRES_PASSWORD=dev`, `password=x`), which are not what a card asking about credential material is
48
+ * for, and it is well under the shortest real token below (a 36-character npm one). */
49
+ const MIN_VALUE = 6;
50
+
51
+ /* A CREDENTIAL RECOGNISABLE WITHOUT ITS KEY: the issuers whose tokens carry their own prefix, the PEM and PuTTY
52
+ * headers that ARE the private key file, and a URL that carries a password in its userinfo (which is the whole
53
+ * content of `.git-credentials`, and the shape a `DATABASE_URL` hides one in).
54
+ *
55
+ * These are checked first and independently of any key, because half of these files have no `key = value` in
56
+ * them at all. */
57
+ const TOKEN_SHAPES = [
58
+ /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/,
59
+ /PuTTY-User-Key-File-\d/,
60
+ // scheme://user:password@host — the password is the point; a Sentry DSN (`https://key@host`) has no colon
61
+ // before the `@` and is deliberately not this.
62
+ /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]{3,}@/i,
63
+ /\bnpm_[A-Za-z0-9]{30,}/,
64
+ /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}/,
65
+ /\bgithub_pat_[A-Za-z0-9_]{50,}/,
66
+ /\bglpat-[A-Za-z0-9_-]{16,}/,
67
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}/,
68
+ /\bsk-[A-Za-z0-9_-]{20,}/,
69
+ /\b(?:sk|rk)_live_[A-Za-z0-9]{16,}/,
70
+ /\bAKIA[0-9A-Z]{16}\b/,
71
+ /\bASIA[0-9A-Z]{16}\b/,
72
+ /\bAIza[0-9A-Za-z_-]{35}\b/,
73
+ /\bhf_[A-Za-z0-9]{30,}/,
74
+ /\bdop_v1_[a-f0-9]{60,}/,
75
+ // A JWT: three base64url segments, the first two of which decode from `{"` and so always begin `ey`.
76
+ /\bey[A-Za-z0-9_-]{10,}\.ey[A-Za-z0-9_-]{10,}\./,
77
+ ];
78
+
79
+ /* A VALUE DEFERRED TO SOMEWHERE ELSE, removed before anything is read, because it is not one value but two
80
+ * things to get wrong. `STRIPE_SECRET={{secret:STRIPE}}` holds no credential — that is the platform's own
81
+ * convention for a file that must not hold one — and yet it reads as a credential twice over: the outer
82
+ * assignment has a substantial-looking value, and the reference's own `secret:STRIPE` is a second `key: value`
83
+ * inside it. Cutting them out first is simpler and steadier than teaching the value patterns to survive them. */
84
+ const DEFERRED_VALUE = /\{\{[^}\n]*\}\}|\$\{[^}\n]*\}/g;
85
+
86
+ // Does this file's text hold something worth a card? Handed the WHOLE file by its caller, which is why the
87
+ // caller (not this) is the one that decides how much of a large file is worth reading.
88
+ export const holdsCredentialMaterial = (file: string): boolean => {
89
+ const text = file.replace(DEFERRED_VALUE, "");
90
+ if (TOKEN_SHAPES.some((pattern) => pattern.test(text))) {
91
+ return true;
92
+ }
93
+ for (const match of text.matchAll(CREDENTIAL_ASSIGNMENT)) {
94
+ const value = (match[1] ?? match[2] ?? match[3] ?? "").trim();
95
+ if (value.length >= MIN_VALUE && !PLACEHOLDER.test(value)) {
96
+ return true;
97
+ }
98
+ }
99
+ return false;
100
+ };
@@ -37,6 +37,18 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
37
37
  // The fleet: every conversation card, its branch, its session ids, its standing.
38
38
  { path: "agents.json", portability: "carry" },
39
39
  { path: "turns/", portability: "carry" },
40
+ /* THE ARMED CONDITION WATCHES, one file per watch, put back at boot (agent/watchers.ts `restoreWatchers`).
41
+ *
42
+ * Carried for the reason loops.json is: an arrangement the agent entered into on the user's behalf and
43
+ * that is still outstanding travels with the conversation that is waiting on it, or a restored sandbox
44
+ * shows a card parked on a condition nothing will ever check. It can be carried safely because the journal
45
+ * holds no credential, only the NAMES of the environment its check ran with (agent/watch-journal.ts); the
46
+ * values are re-derived on the target from whatever capabilities it actually has, so a watch landing
47
+ * somewhere without them fails its check honestly and ends in a wake that says so, rather than arriving
48
+ * with a working copy of a token the bundle was never supposed to carry. A watch whose isolated checkout
49
+ * did not travel (those are `derived`) is dropped by the restore rather than re-armed against a tree that
50
+ * is not there. */
51
+ { path: "watches/", portability: "carry" },
40
52
  { path: "transcripts/", portability: "carry" },
41
53
  // What each message can be put back to, a workspace checkpoint, or an isolated conversation's own commits.
42
54
  // Carried WITH the transcripts and the scopes above, because it is the join between them: without it a
package/src/hostnames.ts CHANGED
@@ -14,7 +14,7 @@ export const sandboxHostname = (id: string, zone: string): string => `${sandboxS
14
14
  // The container sshd hostname the desktop-sync (Mutagen) reaches over the sandbox tunnel: `ssh-<id>.<zone>`.
15
15
  export const sshHostname = (id: string, zone: string): string => `ssh-${id}.${zone}`;
16
16
 
17
- /* The LOOPBACK name: `local-<id>.<zone>`, an A record pointing at 127.0.0.1.
17
+ /* The LOOPBACK name: `<id>.local.<zone>`, resolving to 127.0.0.1 under ONE wildcard record for the whole zone.
18
18
  *
19
19
  * A public DNS name for a private address looks odd until you ask what the alternative is. A browser on the
20
20
  * same machine as the sandbox can reach its daemon in microseconds instead of crossing to a Cloudflare edge
@@ -23,9 +23,25 @@ export const sshHostname = (id: string, zone: string): string => `ssh-${id}.${zo
23
23
  * one; this can. The daemon holds the key and gets the certificate by proving control of the zone over
24
24
  * DNS-01 (there is nothing on the public internet for a CA to connect to).
25
25
  *
26
+ * IT IS A LABEL DEEPER THAN THE OTHER NAMES, and that is the entire point rather than a naming preference. A
27
+ * DNS wildcard matches ONE label, so `*.<zone>` cannot cover `<id>.local.<zone>` and `*.local.<zone>` covers
28
+ * every sandbox that will ever exist: one record for the platform, not one per sandbox. The shape it replaced,
29
+ * `local-<id>.<zone>`, needed a record each, and a zone has a hard per-record quota (Cloudflare 81045). Every
30
+ * OTHER per-sandbox record went away with the move to the zrok hub, so this was the last thing consuming that
31
+ * quota, and when the zone filled the platform could no longer write the record OR the ACME challenge beside
32
+ * it: the certified shortcut stopped resolving, every browser fell back to the plain-http loopback, and that
33
+ * transport is HTTP/1.1 with six connections per origin (see the editor's streamBudget.ts for what that cost).
34
+ * A quota nothing can exhaust is the fix that keeps working.
35
+ *
26
36
  * It discloses nothing: the id is already the leading label of the sandbox's public hostname, and the address
27
37
  * it resolves to is every machine's own loopback. */
28
- export const localHostname = (id: string, zone: string): string => `local-${id}.${zone}`;
38
+ export const localHostname = (id: string, zone: string): string => `${id}.${LOCAL_LABEL}.${zone}`;
39
+
40
+ // The label the loopback names live under, so the wildcard has something to be a wildcard OF.
41
+ export const LOCAL_LABEL = "local";
42
+
43
+ // The single record that answers for all of them. Never per-sandbox, never reaped.
44
+ export const localWildcardHostname = (zone: string): string => `*.${LOCAL_LABEL}.${zone}`;
29
45
 
30
46
  // What that record points at, and the reason it is safe to publish: every resolver on earth gets 127.0.0.1.
31
47
  export const LOCAL_ADDRESS = "127.0.0.1";
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ import { grokContract } from "./contracts/grok.contract.js";
19
19
  import { historyContract } from "./contracts/history.contract.js";
20
20
  import { intenticContract } from "./contracts/intentic.contract.js";
21
21
  import { inventoryContract } from "./contracts/inventory.contract.js";
22
+ import { issuesContract } from "./contracts/issues.contract.js";
22
23
  import { logsContract } from "./contracts/logs.contract.js";
23
24
  import { loopsContract } from "./contracts/loops.contract.js";
24
25
  import { panelsContract } from "./contracts/panels.contract.js";
@@ -66,6 +67,7 @@ export { webextContract } from "./contracts/webext.contract.js";
66
67
  export { runnerContract } from "./contracts/runner.contract.js";
67
68
  export { intenticContract } from "./contracts/intentic.contract.js";
68
69
  export { inventoryContract } from "./contracts/inventory.contract.js";
70
+ export { issuesContract } from "./contracts/issues.contract.js";
69
71
  export { logsContract } from "./contracts/logs.contract.js";
70
72
  export { REQUEST_ID_EVIDENCE_ROUTE, REQUEST_ID_HEADER } from "./request-id.js";
71
73
  export { loopsContract } from "./contracts/loops.contract.js";
@@ -108,6 +110,7 @@ export * from "./agent-catalog.js";
108
110
  export * from "./agent-run-model.js";
109
111
  export * from "./capability-env.js";
110
112
  export * from "./command-classes.js";
113
+ export * from "./credential-material.js";
111
114
  export * from "./capability-secrets.js";
112
115
  export * from "./conversation-ids.js";
113
116
  export * from "./documents.js";
@@ -151,6 +154,7 @@ export * from "./schemas/history.js";
151
154
  export * from "./schemas/hosts.js";
152
155
  export * from "./schemas/intentic.js";
153
156
  export * from "./schemas/inventory.js";
157
+ export * from "./schemas/issues.js";
154
158
  export * from "./schemas/logs.js";
155
159
  export * from "./schemas/loops.js";
156
160
  export * from "./schemas/maintenance.js";
@@ -213,6 +217,7 @@ export const sandboxContract = {
213
217
  history: historyContract,
214
218
  workspace: workspaceContract,
215
219
  inventory: inventoryContract,
220
+ issues: issuesContract,
216
221
  logs: logsContract,
217
222
  loops: loopsContract,
218
223
  panels: panelsContract,
@@ -77,7 +77,9 @@ describe(`routeShapes`, () => {
77
77
  it(`changes the fingerprint when a field is added`, () => {
78
78
  const before = routeShapes(shaped(z.object({ a: z.string() })))[`vpn.list`];
79
79
  const after = routeShapes(shaped(z.object({ a: z.string(), b: z.number() })))[`vpn.list`];
80
- expect(before).toBeDefined();
80
+ // A fingerprint is a string, and saying so is what makes the line below mean something: with `before`
81
+ // undefined and `after` a real shape, "they differ" is true for the wrong reason.
82
+ expect(before).toEqual(expect.any(String));
81
83
  expect(after).not.toBe(before);
82
84
  });
83
85
 
@@ -99,7 +101,7 @@ describe(`routeShapes`, () => {
99
101
  const one = z.object({ a: z.string().default(`x`) });
100
102
  const asOutput = routeShapes(shaped(one))[`vpn.list`];
101
103
  const asInput = routeShapes({ vpn: { list: oc.route({ method: "GET", path: "/vpn" }).input(one) } })[`vpn.list`];
102
- expect(asOutput).toBeDefined();
104
+ expect(asOutput).toEqual(expect.any(String));
103
105
  expect(asInput).not.toBe(asOutput);
104
106
  });
105
107
 
@@ -69,6 +69,22 @@ const RUNTIME_DOMAINS = [
69
69
  // is why the daemon rate-limits this domain rather than pushing every mutation (see runtime-watch.ts).
70
70
  { domain: "subagents", invalidates: [["subagents"]] },
71
71
 
72
+ /* THE MACHINES ON THE OTHER END OF A SOCKET, three domains that are one story: the user's computers, the
73
+ * browsers holding the extension, and this sandbox's runners.
74
+ *
75
+ * Announced, and about as announced as a fact can be. "Online" here is not sampled, inferred or timed out
76
+ * of, it IS a socket in this process: the hub accepts one, replaces one, drops one on a failed heartbeat, or
77
+ * cuts one on a revoke, and those four moments are the entire set of ways the answer changes. Nothing on
78
+ * disk moves, no pane appears, and no other feed could carry it.
79
+ *
80
+ * They land on `capabilities` because a host or webext card's state is LITERALLY the hub's answer
81
+ * (handlers/host.ts: `hub.online(id) ? active : pending`), which is what a person watches while they paste a
82
+ * pairing command into a laptop. That wait is the whole reason these are here: it was three seconds of
83
+ * polling per card, running only because nobody had told the browser that the daemon already knew. */
84
+ { domain: "hosts", invalidates: [["capabilities"], ["computers"]] },
85
+ { domain: "webext", invalidates: [["capabilities"]] },
86
+ { domain: "runners", invalidates: [["runners"]] },
87
+
72
88
  /* The post queue, when the DAEMON moves it rather than the owner. Approving is the owner's own mutation and
73
89
  * refetches itself, but everything after that happens while nobody is touching the page: a held post coming
74
90
  * due, a Discord send landing, a publish turn writing back what went out. Those are the moments the row on
@@ -74,7 +74,7 @@ export type AgentOrigin = z.infer<typeof AgentOriginSchema>;
74
74
  * gate); never sent by a client. Chat and loops are absent deliberately: both begin with the owner's own
75
75
  * click, and holding the owner's work in their own queue is a queue entry that says nothing (the same argument
76
76
  * fireAutomation's `cleared` makes about a by-hand fire). */
77
- export const WakeSourceSchema = z.enum(["schedule", "event", "listener", "webchat", "workspace", "workflow"]);
77
+ export const WakeSourceSchema = z.enum(["schedule", "event", "listener", "webchat", "issues", "workspace", "workflow"]);
78
78
  export type WakeSource = z.infer<typeof WakeSourceSchema>;
79
79
  // One admission verdict the owner can configure: let it run, hold it for approval, or refuse it outright.
80
80
  export const AdmissionRuleSchema = z.enum(["allow", "hold", "deny"]);
@@ -96,7 +96,11 @@ export const CommandClassSchema = z.enum([
96
96
  * owner wrote no rule, which is why it is separate from files.destructive rather than a shade of it:
97
97
  * `rm -rf build` is ordinary work in a disposable container and `rm -rf /` is the end of the machine. */
98
98
  "system.destructive",
99
- // Names credential material: a .env file, an ssh key, ~/.aws/credentials, .npmrc, a stored token file.
99
+ /* READS credential material: a `{{secret:NAME}}` reference (which becomes the value on the way into the
100
+ * process), or a file that actually holds one — a dotenv, a private key, ~/.aws/credentials, an npmrc.
101
+ * "Actually" is load-bearing and is checked rather than assumed where the caller can open the file: an
102
+ * npmrc with no token in it, a dotenv of ports, a public key, a path that is not there, are none of them
103
+ * this class, however much they look like it from the command line. See command-classes.ts. */
100
104
  "secrets.access",
101
105
  // Publishes outward and irreversibly: npm/pnpm/yarn/cargo publish, gh release create, docker push.
102
106
  "package.publish",
@@ -114,6 +118,15 @@ export const AdmissionPolicySchema = z.object({
114
118
  event: AdmissionRuleSchema.default("allow"),
115
119
  listener: AdmissionRuleSchema.default("allow"),
116
120
  webchat: AdmissionRuleSchema.default("allow"),
121
+ /* THE ONE SOURCE THAT HOLDS BY DEFAULT, and the exception is argued rather than assumed. A Front Desk wake
122
+ * runs the read-only Front Desk persona: a stranger drives the prompt and the toolbox is a shelf of two.
123
+ * A bug-report wake is the opposite on both counts, it is pointed at the repository with the powers to
124
+ * change it, and the brief is a stack trace and a sentence somebody else's browser wrote.
125
+ *
126
+ * Held is not blocked: the wake sits in the same approvals queue every other hold uses, with the issue's
127
+ * own title on the card, and one click runs it. An owner who wants their crashes fixed while they sleep
128
+ * sets this to `allow` deliberately, which is the direction that decision should have to be made in. */
129
+ issues: AdmissionRuleSchema.default("hold"),
117
130
  workspace: AdmissionRuleSchema.default("allow"),
118
131
  workflow: z.enum(["allow", "deny"]).default("allow"),
119
132
  });
@@ -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
- // webchat only: the website origins allowed to POST to the widget endpoint. Absent/empty ⇒ none admitted.
112
- allowedOrigins: z.array(z.string()).optional().describe("Which websites may reach the chat widget. Absent or empty admits nobody."),
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
  *
@@ -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;