@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
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { holdsCredentialMaterial } from "./credential-material.js";
|
|
3
|
+
|
|
4
|
+
/* The two directions are not symmetric and the tests are written to say so: a MISS here costs one permission
|
|
5
|
+
* card that should not have been raised, and a WRONG CLEAR un-gates a real credential read. So the "no" cases
|
|
6
|
+
* are the ones that have to be exactly right, and every "yes" case is a real file's real shape. */
|
|
7
|
+
|
|
8
|
+
describe("files that hold a credential", () => {
|
|
9
|
+
test("an npmrc with a token", () => {
|
|
10
|
+
expect(holdsCredentialMaterial("//registry.npmjs.org/:_authToken=npm_wCq3nTvR8xLm2ZbKp7HdJyE4sUaF6gN0iQ1t\n")).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("an aws credentials ini", () => {
|
|
14
|
+
expect(
|
|
15
|
+
holdsCredentialMaterial("[default]\naws_access_key_id = AKIAIOSFODNN7EXAMPLE\naws_secret_access_key = wJalrXUtnFEMI/K7MDENG\n"),
|
|
16
|
+
).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("a private key file, whatever generated it", () => {
|
|
20
|
+
for (const text of [
|
|
21
|
+
"-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n-----END OPENSSH PRIVATE KEY-----\n",
|
|
22
|
+
"-----BEGIN RSA PRIVATE KEY-----\nMIIEow\n",
|
|
23
|
+
"-----BEGIN PRIVATE KEY-----\nMIIEvQ\n",
|
|
24
|
+
"PuTTY-User-Key-File-3: ssh-ed25519\nEncryption: none\n",
|
|
25
|
+
]) {
|
|
26
|
+
expect(holdsCredentialMaterial(text), text.slice(0, 30)).toBe(true);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// `.git-credentials` is nothing but these lines, and a dotenv hides the same secret inside a connection
|
|
31
|
+
// string, where no key names it.
|
|
32
|
+
test("a password carried in a URL", () => {
|
|
33
|
+
expect(holdsCredentialMaterial("https://radarsu:ghp_S8kQ2mVx@github.com\n")).toBe(true);
|
|
34
|
+
expect(holdsCredentialMaterial("DATABASE_URL=postgres://app:Rk29fPqz@db.internal:5432/app\n")).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("a dotenv with one real value among the ordinary ones", () => {
|
|
38
|
+
expect(holdsCredentialMaterial("PORT=3000\nNODE_ENV=production\nSTRIPE_SECRET=sk_live_51H8xQzRvKpLmNbTy\n")).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a json credentials file", () => {
|
|
42
|
+
expect(holdsCredentialMaterial('{"accessToken":"ya29.a0AfB_bJq2Lm","expiresAt":1767000000}')).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// The issuers whose tokens announce themselves, found without any key naming them.
|
|
46
|
+
test("a token that carries its own prefix", () => {
|
|
47
|
+
for (const text of [
|
|
48
|
+
"ghp_16C7e42F292c6912E7710c838347Ae178B4a",
|
|
49
|
+
"xoxb-2410-1230-AbCdEfGhIjKlMnOpQrSt",
|
|
50
|
+
"AKIAIOSFODNN7EXAMPLE",
|
|
51
|
+
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
|
|
52
|
+
]) {
|
|
53
|
+
expect(holdsCredentialMaterial(text), text.slice(0, 20)).toBe(true);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("files that do not", () => {
|
|
59
|
+
/* THE FILE THAT STARTED THIS. An npmrc is the most-named credential path in this workspace's own commands
|
|
60
|
+
* and most of them are three lines of registry config. */
|
|
61
|
+
test("an npmrc with only registry config", () => {
|
|
62
|
+
expect(holdsCredentialMaterial("registry=https://registry.npmjs.org/\nengine-strict=true\nstore-dir=/root/.pnpm-store\n")).toBe(
|
|
63
|
+
false,
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a dotenv of ports and flags", () => {
|
|
68
|
+
expect(holdsCredentialMaterial("PORT=3000\nNODE_ENV=development\nVITE_API_URL=http://localhost:8080\nLOG_LEVEL=debug\n")).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/* THE TEMPLATE THAT IS STILL A TEMPLATE. A key named `TOKEN` proves nothing on its own — half the dotenvs
|
|
72
|
+
* in a monorepo are this file, waiting for someone to fill them in. */
|
|
73
|
+
test("a dotenv whose credential keys are still placeholders", () => {
|
|
74
|
+
expect(
|
|
75
|
+
holdsCredentialMaterial(
|
|
76
|
+
[
|
|
77
|
+
"GITHUB_TOKEN=",
|
|
78
|
+
"NPM_TOKEN=${NPM_TOKEN}",
|
|
79
|
+
"API_KEY=<your-api-key>",
|
|
80
|
+
'CLIENT_SECRET=""',
|
|
81
|
+
"DB_PASSWORD=changeme",
|
|
82
|
+
"SLACK_TOKEN=xxxxxxxx",
|
|
83
|
+
"STRIPE_SECRET={{secret:STRIPE}}",
|
|
84
|
+
"AUTH_TOKEN=your-token-here",
|
|
85
|
+
"SESSION_SECRET=REDACTED",
|
|
86
|
+
].join("\n"),
|
|
87
|
+
),
|
|
88
|
+
).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// The public half of a keypair, and the host list beside it: named in the same directory, credential
|
|
92
|
+
// material in neither.
|
|
93
|
+
test("the public files an ssh directory is full of", () => {
|
|
94
|
+
expect(holdsCredentialMaterial("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH1x radarsu@omen\n")).toBe(false);
|
|
95
|
+
expect(holdsCredentialMaterial("github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzr\n")).toBe(false);
|
|
96
|
+
expect(holdsCredentialMaterial("Host github.com\n User git\n IdentityFile ~/.ssh/id_ed25519\n")).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a key whose name only looks like one", () => {
|
|
100
|
+
expect(holdsCredentialMaterial("TOKEN_EXPIRY=3600\nREFRESH_TOKEN_URL=https://auth.example.com/refresh\nMAX_TOKENS=8192\n")).toBe(
|
|
101
|
+
false,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// A dev default is a real password in the strictest reading and nothing this class exists to stop; the
|
|
106
|
+
// length floor is what draws that line.
|
|
107
|
+
test("a dev-compose default is not what the card is for", () => {
|
|
108
|
+
expect(holdsCredentialMaterial("POSTGRES_PASSWORD=dev\nREDIS_PASSWORD=x\n")).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("an empty file", () => {
|
|
112
|
+
expect(holdsCredentialMaterial("")).toBe(false);
|
|
113
|
+
expect(holdsCredentialMaterial("\n\n# nothing here\n")).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// A URL with a port is not a URL with a password: `user:pass@` needs both halves and the `@`.
|
|
117
|
+
test("an ordinary url is not userinfo", () => {
|
|
118
|
+
expect(holdsCredentialMaterial("API=http://localhost:8080/v1\nSENTRY_DSN=https://abc123@o1.ingest.sentry.io/1\n")).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -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
|
+
};
|
package/src/history-state.ts
CHANGED
|
@@ -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:
|
|
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 =>
|
|
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,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { hostOwnerId, mintReachabilityGrant, verifyReachabilityGrant } from "./ingress-contract.js";
|
|
4
|
+
|
|
5
|
+
const pemPair = (): { privateKey: string; publicKey: string } => {
|
|
6
|
+
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
|
7
|
+
return {
|
|
8
|
+
privateKey: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
|
9
|
+
publicKey: publicKey.export({ type: "spki", format: "pem" }).toString(),
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const SANDBOX_ID = "abc123def456";
|
|
14
|
+
|
|
15
|
+
describe("reachability grant", () => {
|
|
16
|
+
it("round-trips through mint and verify", () => {
|
|
17
|
+
const keys = pemPair();
|
|
18
|
+
const token = mintReachabilityGrant(keys.privateKey, SANDBOX_ID, 1_700_000_000_123);
|
|
19
|
+
expect(verifyReachabilityGrant(keys.publicKey, token)).toEqual({ sandboxId: SANDBOX_ID, issuedAt: 1_700_000_000 });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("refuses a grant signed by another key", () => {
|
|
23
|
+
const token = mintReachabilityGrant(pemPair().privateKey, SANDBOX_ID, Date.now());
|
|
24
|
+
expect(verifyReachabilityGrant(pemPair().publicKey, token)).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("refuses a tampered payload", () => {
|
|
28
|
+
const keys = pemPair();
|
|
29
|
+
const token = mintReachabilityGrant(keys.privateKey, SANDBOX_ID, Date.now());
|
|
30
|
+
const [prefix, , signature] = token.split(".");
|
|
31
|
+
const forged = Buffer.from(JSON.stringify({ sub: "000000000000", iat: 1 }), "utf8").toString("base64url");
|
|
32
|
+
expect(verifyReachabilityGrant(keys.publicKey, `${prefix}.${forged}.${signature}`)).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("answers undefined for garbage rather than throwing", () => {
|
|
36
|
+
const keys = pemPair();
|
|
37
|
+
for (const junk of ["", "ig1", "ig1..", "nonsense.a.b", "ig2.a.b", "ig1.%%%.%%%"]) {
|
|
38
|
+
expect(verifyReachabilityGrant(keys.publicKey, junk)).toBeUndefined();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("refuses to mint for something that is not a sandbox id", () => {
|
|
43
|
+
expect(() => mintReachabilityGrant(pemPair().privateKey, "not-an-id", Date.now())).toThrow(/12-hex/);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("hostOwnerId", () => {
|
|
48
|
+
it("owns the daemon's own name and every labelled name", () => {
|
|
49
|
+
expect(hostOwnerId(`sandbox-${SANDBOX_ID}.sbx.example.dev`)).toBe(SANDBOX_ID);
|
|
50
|
+
expect(hostOwnerId(`preview-operator-${SANDBOX_ID}.sbx.example.dev`)).toBe(SANDBOX_ID);
|
|
51
|
+
expect(hostOwnerId(`port-0f0f0f0f0f0f-${SANDBOX_ID}.sbx.example.dev`)).toBe(SANDBOX_ID);
|
|
52
|
+
expect(hostOwnerId(`public-1a2b3c4d5e6f-${SANDBOX_ID}.sbx.example.dev`)).toBe(SANDBOX_ID);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("ignores a port suffix", () => {
|
|
56
|
+
expect(hostOwnerId(`sandbox-${SANDBOX_ID}.sbx.example.dev:443`)).toBe(SANDBOX_ID);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("owns nothing that does not end in a 12-hex tail", () => {
|
|
60
|
+
// The ingress's own door, the zone apex, the loopback name's bare-id label, and a near-miss tail.
|
|
61
|
+
for (const host of ["ingress.sbx.example.dev", "sbx.example.dev", `${SANDBOX_ID}.local.sbx.example.dev`, "sandbox-abc123def45.sbx.example.dev", ""]) {
|
|
62
|
+
expect(hostOwnerId(host)).toBeUndefined();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createPrivateKey, createPublicKey, sign as edSign, verify as edVerify } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/* THE INGRESS CONTRACT: how a sandbox is reached now that the reachability fabric is the platform's OWN edge
|
|
4
|
+
* (the `@intentic/ingress` app on Fly) instead of a zrok hub. Node-only (crypto), like ./tunnel-ids beside it;
|
|
5
|
+
* shared by the platform (mints grants), the ingress (verifies grants, routes hosts), and the daemon (dials
|
|
6
|
+
* the tunnel). The three MUST agree on every string in this file, which is why it exists.
|
|
7
|
+
*
|
|
8
|
+
* WHAT REPLACED WHAT. Under zrok, reachability was STATE: an account minted per sandbox over the hub's admin
|
|
9
|
+
* API, names claimed one by one, shares bound to them, and a reaper collecting what the soft-deletes leaked.
|
|
10
|
+
* Every piece of that state existed to answer one question — "which sandbox may serve this hostname?" — that
|
|
11
|
+
* the hostnames ALREADY answer by construction: every public name a sandbox serves ends in its own 12-hex id
|
|
12
|
+
* (`sandbox-<id>`, `preview-<panel>-<id>`, `port-<slot>-<id>`, `public-<slot>-<id>`; hostnames.ts is the
|
|
13
|
+
* single source). So ownership is a PARSE, not a registry, and the only thing that has to be minted is proof
|
|
14
|
+
* of identity: a grant, signed by the platform, saying "the bearer is sandbox <id>". Provisioning reachability
|
|
15
|
+
* becomes a pure function — no hub round trip, no row to cache a token on, no orphan to reconcile, and
|
|
16
|
+
* revoking it is deleting the sandbox row (the ingress asks the platform on register, below).
|
|
17
|
+
*
|
|
18
|
+
* THE FLOW. The daemon dials ONE outbound WebSocket to the ingress (INGRESS_TUNNEL_PATH) presenting its grant
|
|
19
|
+
* in INGRESS_GRANT_HEADER. The ingress verifies the signature offline, registers the tunnel under grant.sub,
|
|
20
|
+
* and from then on routes every edge request whose Host's leftmost label ends in `-<that id>` (or is exactly
|
|
21
|
+
* `sandbox-<id>`) down that tunnel. A second tunnel for the same id DISPLACES the first — the new container is
|
|
22
|
+
* by definition the live one, which is what buries the zrok-era stale-share reclaim dance (a recreated box
|
|
23
|
+
* used to fight the hub over names its dead predecessor still held; here the fight cannot exist).
|
|
24
|
+
*
|
|
25
|
+
* THE DATA PLANE over the tunnel is an HTTP/2 cleartext session runs over the WebSocket's binary stream:
|
|
26
|
+
* the ingress side opens an http2 CLIENT session over the duplex (node's http2.connect with createConnection),
|
|
27
|
+
* the daemon side feeds the duplex to an http2 SERVER session, and each edge request becomes one h2 stream
|
|
28
|
+
* with the original :authority preserved, which the daemon's client forwards to its own loopback listener
|
|
29
|
+
* (the Hono app already dispatches previews by Host). WebSocket upgrades ride CONNECT-method streams carrying
|
|
30
|
+
* the raw upgraded bytes. All of it is node core — the mux, flow control and per-stream backpressure are
|
|
31
|
+
* h2's own, not ours to reimplement. The implementation lives in ./ingress-protocol.ts (both halves, one
|
|
32
|
+
* owner); this file pins only what every party must agree on.
|
|
33
|
+
*
|
|
34
|
+
* WHY PER-REQUEST ROUTING IS NOT OPTIONAL: the edge terminates TLS under ONE wildcard certificate, and h2
|
|
35
|
+
* browsers coalesce connections across every hostname a certificate covers — one TCP connection can carry
|
|
36
|
+
* `sandbox-a…` and `preview-x-b…` interleaved. Routing a CONNECTION by its first Host would send one
|
|
37
|
+
* sandbox's requests to another. The unit of routing is the request (h2 stream), never the connection. */
|
|
38
|
+
|
|
39
|
+
// ── The reachability grant ──────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/* Version prefix, so a future shape can coexist during a key rotation. Not a negotiation: an ingress that
|
|
42
|
+
* does not know a prefix refuses the tunnel, and the box retries until its operator updates something. */
|
|
43
|
+
const GRANT_PREFIX = "ig1";
|
|
44
|
+
|
|
45
|
+
const base64url = (bytes: Buffer): string => bytes.toString("base64url");
|
|
46
|
+
|
|
47
|
+
// The signed claim. `sub` is the sandbox's 12-hex id (sandboxIdFromToken in ./tunnel-ids); `iat` is seconds.
|
|
48
|
+
// Deliberately no expiry: the grant lives in a container's env for the container's whole life, and the
|
|
49
|
+
// revocation that matters (the sandbox being deleted) is answered by the platform on register, not by time.
|
|
50
|
+
export interface ReachabilityGrant {
|
|
51
|
+
readonly sandboxId: string;
|
|
52
|
+
readonly issuedAt: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const SANDBOX_ID = /^[0-9a-f]{12}$/;
|
|
56
|
+
|
|
57
|
+
/* Mint a grant: Ed25519 over the canonical payload bytes. Ed25519 because node signs/verifies it with key
|
|
58
|
+
* objects alone (no hash negotiation, no padding modes), signatures are 64 bytes, and the platform already
|
|
59
|
+
* depends on nothing for it — the private key is config (PEM, PKCS8), the public key rides the ingress env. */
|
|
60
|
+
export const mintReachabilityGrant = (privateKeyPem: string, sandboxId: string, issuedAtMs: number): string => {
|
|
61
|
+
if (!SANDBOX_ID.test(sandboxId)) {
|
|
62
|
+
throw new Error(`a reachability grant names a 12-hex sandbox id, got "${sandboxId}"`);
|
|
63
|
+
}
|
|
64
|
+
const payload = Buffer.from(JSON.stringify({ sub: sandboxId, iat: Math.floor(issuedAtMs / 1000) }), "utf8");
|
|
65
|
+
const signature = edSign(null, payload, createPrivateKey(privateKeyPem));
|
|
66
|
+
return `${GRANT_PREFIX}.${base64url(payload)}.${base64url(signature)}`;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/* Verify a grant against the platform's public key. Every malformed shape answers undefined rather than
|
|
70
|
+
* throwing: this runs on the ingress's unauthenticated door, where a garbage token is weather, not a fault. */
|
|
71
|
+
export const verifyReachabilityGrant = (publicKeyPem: string, token: string): ReachabilityGrant | undefined => {
|
|
72
|
+
const parts = token.split(".");
|
|
73
|
+
if (parts.length !== 3 || parts[0] !== GRANT_PREFIX) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const payload = Buffer.from(parts[1] as string, "base64url");
|
|
78
|
+
const signature = Buffer.from(parts[2] as string, "base64url");
|
|
79
|
+
if (!edVerify(null, payload, createPublicKey(publicKeyPem), signature)) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const parsed = JSON.parse(payload.toString("utf8")) as { sub?: unknown; iat?: unknown };
|
|
83
|
+
if (typeof parsed.sub !== "string" || !SANDBOX_ID.test(parsed.sub) || typeof parsed.iat !== "number") {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
return { sandboxId: parsed.sub, issuedAt: parsed.iat };
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// ── Host → owner routing ────────────────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/* Which sandbox may serve this Host. The leftmost DNS label either IS `sandbox-<id>` or ends in `-<id>`
|
|
95
|
+
* (preview/port/public labels, hostnames.ts) — a fixed-length tail, so label keys containing `-` stay
|
|
96
|
+
* unambiguous. Anything else (the ingress's own name, the zone apex, a stray subdomain) answers undefined,
|
|
97
|
+
* which the ingress turns into its 404. The loopback name (`<id>.local.<zone>`) never reaches the ingress —
|
|
98
|
+
* it resolves to 127.0.0.1 — and its bare-id label deliberately does not match here. */
|
|
99
|
+
export const hostOwnerId = (host: string): string | undefined => {
|
|
100
|
+
const label = host.split(":")[0]?.split(".")[0] ?? "";
|
|
101
|
+
const match = /-([0-9a-f]{12})$/.exec(label);
|
|
102
|
+
return match === null ? undefined : match[1];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// ── Wire constants ──────────────────────────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/* The tunnel door on the ingress. Versioned in the path so a v2 session shape is a new door, not a flag day:
|
|
108
|
+
* the ingress serves both for as long as old containers exist. Checked BEFORE host routing — the ingress's own
|
|
109
|
+
* hostname carries no sandbox id on purpose. */
|
|
110
|
+
export const INGRESS_TUNNEL_PATH = "/tunnel/v1";
|
|
111
|
+
|
|
112
|
+
// The grant rides a header on the tunnel upgrade (a Node client can set one; this is never a browser).
|
|
113
|
+
export const INGRESS_GRANT_HEADER = "x-intentic-grant";
|
|
114
|
+
|
|
115
|
+
/* The env vocabulary, every lane (connect one-liner, compose file, hosted machine env) hands the same pair
|
|
116
|
+
* down and the entrypoint/daemon read exactly these names. SANDBOX_PUBLIC_URL is unchanged from the zrok era
|
|
117
|
+
* and stays beside them. */
|
|
118
|
+
export const ENV_INGRESS_URL = "INGRESS_URL";
|
|
119
|
+
export const ENV_SANDBOX_GRANT = "SANDBOX_GRANT";
|
|
120
|
+
|
|
121
|
+
// ── The daemon-side surface (pinned for the boot wiring) ────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/* The daemon-side tunnel behavior this contract requires. Dial, register, forward to the loopback listener,
|
|
124
|
+
* reconnect forever with backoff —
|
|
125
|
+
* the tunnel is the sandbox's reachability, so like the zrok agent's restart loop it never gives up, it only
|
|
126
|
+
* ever waits longer. close() is for shutdown and tests. */
|
|
127
|
+
export interface IngressTunnelOptions {
|
|
128
|
+
// e.g. https://ingress.<zone>. The daemon derives the wss:// door itself (INGRESS_TUNNEL_PATH).
|
|
129
|
+
readonly url: string;
|
|
130
|
+
readonly grant: string;
|
|
131
|
+
// The daemon's own loopback listener; every h2 stream lands there as a plain HTTP/1.1 request or upgrade.
|
|
132
|
+
readonly targetPort: number;
|
|
133
|
+
readonly log: (message: string, error?: unknown) => void;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface IngressTunnelHandle {
|
|
137
|
+
readonly close: () => Promise<void>;
|
|
138
|
+
// For /health and the boot log: whether the tunnel currently holds a registered session.
|
|
139
|
+
readonly connected: () => boolean;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export type StartIngressTunnel = (options: IngressTunnelOptions) => IngressTunnelHandle;
|
|
143
|
+
|
|
144
|
+
/* ── What the INGRESS side must also honor (spec, enforced by ingress-protocol tests) ──────────────────────
|
|
145
|
+
*
|
|
146
|
+
* • Register: verify the grant offline; then, when PLATFORM_URL is configured, ask the platform whether the
|
|
147
|
+
* sandbox still exists (GET /api/reachability/<id>, 200/404, answer cached; fail-OPEN on a platform that
|
|
148
|
+
* does not answer — reachability must not depend on the platform being up, that is the whole point of the
|
|
149
|
+
* platform being off the hot path). A 404 refuses the tunnel: that is revocation.
|
|
150
|
+
* • Displacement: a new tunnel for an id closes the old session (code 4001) and takes the registration.
|
|
151
|
+
* • Liveness: WebSocket ping every 15s; a peer silent for 45s is dead and unregistered.
|
|
152
|
+
* • Routing: request host → hostOwnerId → registered tunnel; no tunnel answers 502 with a body naming the
|
|
153
|
+
* sandbox label (the browser's availability flow reads any 5xx as "sandbox unreachable" and drives wake).
|
|
154
|
+
* • The tunnel door itself (INGRESS_TUNNEL_PATH) and anything not carrying a sandbox-id host answer on the
|
|
155
|
+
* ingress directly; they are never routed. */
|
package/src/routes.test.ts
CHANGED
|
@@ -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
|
-
|
|
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).
|
|
104
|
+
expect(asOutput).toEqual(expect.any(String));
|
|
103
105
|
expect(asInput).not.toBe(asOutput);
|
|
104
106
|
});
|
|
105
107
|
|
package/src/runtime-state.ts
CHANGED
|
@@ -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
|
package/src/schemas/agent.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|