@intentic/sandbox-contract 1.331.0 → 1.332.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 +15 -0
- package/dist/chores/probes.d.ts.map +1 -1
- package/dist/chores/probes.js.map +1 -1
- package/dist/contracts/accounts.contract.d.ts +11 -0
- package/dist/contracts/accounts.contract.d.ts.map +1 -1
- package/dist/contracts/agent.contract.d.ts +37 -0
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agent.contract.js +14 -3
- package/dist/contracts/agent.contract.js.map +1 -1
- package/dist/contracts/agents.contract.d.ts +29 -0
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/automations.contract.d.ts +2 -0
- package/dist/contracts/automations.contract.d.ts.map +1 -1
- package/dist/contracts/device.contract.d.ts +7 -0
- package/dist/contracts/device.contract.d.ts.map +1 -1
- package/dist/contracts/git.contract.d.ts +2 -0
- package/dist/contracts/git.contract.d.ts.map +1 -1
- package/dist/contracts/logs.contract.d.ts +10 -0
- package/dist/contracts/logs.contract.d.ts.map +1 -1
- package/dist/contracts/needs.contract.d.ts +7 -0
- package/dist/contracts/needs.contract.d.ts.map +1 -1
- package/dist/contracts/offload.contract.d.ts +70 -0
- package/dist/contracts/offload.contract.d.ts.map +1 -1
- package/dist/contracts/offload.contract.js +10 -1
- package/dist/contracts/offload.contract.js.map +1 -1
- package/dist/contracts/privacy.contract.d.ts +14 -0
- package/dist/contracts/privacy.contract.d.ts.map +1 -1
- package/dist/contracts/privacy.contract.js +11 -1
- package/dist/contracts/privacy.contract.js.map +1 -1
- package/dist/contracts/providers.contract.d.ts +3 -0
- package/dist/contracts/providers.contract.d.ts.map +1 -1
- package/dist/contracts/runner.contract.d.ts +103 -2
- package/dist/contracts/runner.contract.d.ts.map +1 -1
- package/dist/contracts/sessions.contract.d.ts +1 -0
- package/dist/contracts/sessions.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.d.ts +72 -94
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.js +19 -1
- package/dist/contracts/settings.contract.js.map +1 -1
- package/dist/contracts/system.contract.d.ts +17 -0
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/contracts/translator.contract.d.ts +4 -0
- package/dist/contracts/translator.contract.d.ts.map +1 -1
- package/dist/events/agent-events.d.ts +46 -0
- package/dist/events/agent-events.d.ts.map +1 -1
- package/dist/events/agent-events.js +6 -0
- package/dist/events/agent-events.js.map +1 -1
- package/dist/events/agent-words.d.ts.map +1 -1
- package/dist/events/agent-words.js +1 -1
- package/dist/events/agent-words.js.map +1 -1
- package/dist/events/resume.d.ts +3 -0
- package/dist/events/resume.d.ts.map +1 -1
- package/dist/events/resume.js +17 -2
- package/dist/events/resume.js.map +1 -1
- package/dist/events/sandbox-notice.d.ts +18 -0
- package/dist/events/sandbox-notice.d.ts.map +1 -1
- package/dist/events/sandbox-notice.js +27 -1
- package/dist/events/sandbox-notice.js.map +1 -1
- package/dist/events/system-events.d.ts +2 -0
- package/dist/events/system-events.d.ts.map +1 -1
- package/dist/events/transcript.d.ts +64 -0
- package/dist/events/transcript.d.ts.map +1 -1
- package/dist/events/transcript.js +17 -1
- package/dist/events/transcript.js.map +1 -1
- package/dist/index.d.ts +313 -124
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/models/agent-catalog.d.ts.map +1 -1
- package/dist/models/agent-catalog.js +8 -7
- package/dist/models/agent-catalog.js.map +1 -1
- package/dist/models/agent-runtimes.d.ts +3 -1
- package/dist/models/agent-runtimes.d.ts.map +1 -1
- package/dist/models/agent-runtimes.js +10 -0
- package/dist/models/agent-runtimes.js.map +1 -1
- package/dist/models/model-order.d.ts +1 -0
- package/dist/models/model-order.d.ts.map +1 -1
- package/dist/models/model-order.js +9 -0
- package/dist/models/model-order.js.map +1 -1
- package/dist/models/model-pins.d.ts.map +1 -1
- package/dist/models/model-pins.js.map +1 -1
- package/dist/models/model-roles.d.ts +25 -18
- package/dist/models/model-roles.d.ts.map +1 -1
- package/dist/models/model-roles.js +23 -27
- package/dist/models/model-roles.js.map +1 -1
- package/dist/models/model-setups.d.ts +46 -0
- package/dist/models/model-setups.d.ts.map +1 -0
- package/dist/models/model-setups.js +86 -0
- package/dist/models/model-setups.js.map +1 -0
- package/dist/models/plan-pools.d.ts +3 -1
- package/dist/models/plan-pools.d.ts.map +1 -1
- package/dist/models/plan-pools.js +18 -6
- package/dist/models/plan-pools.js.map +1 -1
- package/dist/models/provider-specs.d.ts +20 -0
- package/dist/models/provider-specs.d.ts.map +1 -1
- package/dist/models/provider-specs.js +13 -1
- package/dist/models/provider-specs.js.map +1 -1
- package/dist/policy/command-classes.d.ts.map +1 -1
- package/dist/policy/command-classes.js +26 -3
- package/dist/policy/command-classes.js.map +1 -1
- package/dist/policy/credential-material.d.ts +6 -0
- package/dist/policy/credential-material.d.ts.map +1 -1
- package/dist/policy/credential-material.js +37 -15
- package/dist/policy/credential-material.js.map +1 -1
- package/dist/policy/search-globs.d.ts +3 -0
- package/dist/policy/search-globs.d.ts.map +1 -1
- package/dist/policy/search-globs.js +118 -0
- package/dist/policy/search-globs.js.map +1 -1
- package/dist/policy/secret-hosts.d.ts +1 -0
- package/dist/policy/secret-hosts.d.ts.map +1 -1
- package/dist/policy/secret-hosts.js +1 -0
- package/dist/policy/secret-hosts.js.map +1 -1
- package/dist/policy/turned-away.d.ts.map +1 -1
- package/dist/policy/turned-away.js +1 -0
- package/dist/policy/turned-away.js.map +1 -1
- package/dist/protocol/doors/runner-protocol.d.ts +9 -0
- package/dist/protocol/doors/runner-protocol.d.ts.map +1 -1
- package/dist/protocol/doors/runner-protocol.js +5 -0
- package/dist/protocol/doors/runner-protocol.js.map +1 -1
- package/dist/protocol/pairing-redeem.d.ts +23 -0
- package/dist/protocol/pairing-redeem.d.ts.map +1 -0
- package/dist/protocol/pairing-redeem.js +44 -0
- package/dist/protocol/pairing-redeem.js.map +1 -0
- package/dist/protocol/peer-mcp-server.d.ts.map +1 -1
- package/dist/protocol/peer-mcp-server.js +1 -1
- package/dist/protocol/peer-mcp-server.js.map +1 -1
- package/dist/protocol/raw/raw-json-routes.d.ts +16 -0
- package/dist/protocol/raw/raw-json-routes.d.ts.map +1 -1
- package/dist/protocol/raw/raw-json-routes.js +2 -0
- package/dist/protocol/raw/raw-json-routes.js.map +1 -1
- package/dist/protocol/raw/raw-routes.d.ts +8 -0
- package/dist/protocol/raw/raw-routes.d.ts.map +1 -1
- package/dist/protocol/raw/raw-routes.js +2 -0
- package/dist/protocol/raw/raw-routes.js.map +1 -1
- package/dist/protocol/sse.d.ts +20 -1
- package/dist/protocol/sse.d.ts.map +1 -1
- package/dist/protocol/sse.js +112 -17
- package/dist/protocol/sse.js.map +1 -1
- package/dist/schemas/agent.d.ts +32 -0
- package/dist/schemas/agent.d.ts.map +1 -1
- package/dist/schemas/agent.js +12 -1
- package/dist/schemas/agent.js.map +1 -1
- package/dist/schemas/automations.d.ts +6 -0
- package/dist/schemas/automations.d.ts.map +1 -1
- package/dist/schemas/automations.js +3 -1
- package/dist/schemas/automations.js.map +1 -1
- package/dist/schemas/devices.d.ts +17 -0
- package/dist/schemas/devices.d.ts.map +1 -1
- package/dist/schemas/devices.js +2 -1
- package/dist/schemas/devices.js.map +1 -1
- package/dist/schemas/engines.d.ts +8 -1
- package/dist/schemas/engines.d.ts.map +1 -1
- package/dist/schemas/engines.js +1 -1
- package/dist/schemas/engines.js.map +1 -1
- package/dist/schemas/git/git-scope.d.ts +20 -0
- package/dist/schemas/git/git-scope.d.ts.map +1 -0
- package/dist/schemas/git/git-scope.js +37 -0
- package/dist/schemas/git/git-scope.js.map +1 -0
- package/dist/schemas/git/git.d.ts +1 -0
- package/dist/schemas/git/git.d.ts.map +1 -1
- package/dist/schemas/git/git.js +9 -1
- package/dist/schemas/git/git.js.map +1 -1
- package/dist/schemas/hosts.d.ts +42 -0
- package/dist/schemas/hosts.d.ts.map +1 -1
- package/dist/schemas/hosts.js +3 -0
- package/dist/schemas/hosts.js.map +1 -1
- package/dist/schemas/keep-warm.d.ts +1 -0
- package/dist/schemas/keep-warm.d.ts.map +1 -1
- package/dist/schemas/keep-warm.js +1 -1
- package/dist/schemas/keep-warm.js.map +1 -1
- package/dist/schemas/logs.d.ts +21 -0
- package/dist/schemas/logs.d.ts.map +1 -1
- package/dist/schemas/logs.js +19 -0
- package/dist/schemas/logs.js.map +1 -1
- package/dist/schemas/metrics.d.ts +12 -0
- package/dist/schemas/metrics.d.ts.map +1 -1
- package/dist/schemas/metrics.js +26 -8
- package/dist/schemas/metrics.js.map +1 -1
- package/dist/schemas/needs.d.ts +19 -0
- package/dist/schemas/needs.d.ts.map +1 -1
- package/dist/schemas/needs.js +17 -3
- package/dist/schemas/needs.js.map +1 -1
- package/dist/schemas/offload.d.ts +154 -0
- package/dist/schemas/offload.d.ts.map +1 -1
- package/dist/schemas/offload.js +34 -0
- package/dist/schemas/offload.js.map +1 -1
- package/dist/schemas/privacy.d.ts +19 -0
- package/dist/schemas/privacy.d.ts.map +1 -1
- package/dist/schemas/privacy.js +17 -2
- package/dist/schemas/privacy.js.map +1 -1
- package/dist/schemas/project-bring-back.d.ts +172 -0
- package/dist/schemas/project-bring-back.d.ts.map +1 -0
- package/dist/schemas/project-bring-back.js +39 -0
- package/dist/schemas/project-bring-back.js.map +1 -0
- package/dist/schemas/providers/handoff.d.ts +77 -0
- package/dist/schemas/providers/handoff.d.ts.map +1 -0
- package/dist/schemas/providers/handoff.js +85 -0
- package/dist/schemas/providers/handoff.js.map +1 -0
- package/dist/schemas/providers/plan-limits.d.ts +27 -0
- package/dist/schemas/providers/plan-limits.d.ts.map +1 -1
- package/dist/schemas/providers/plan-limits.js +8 -1
- package/dist/schemas/providers/plan-limits.js.map +1 -1
- package/dist/schemas/providers/provider-oauth.d.ts +4 -0
- package/dist/schemas/providers/provider-oauth.d.ts.map +1 -1
- package/dist/schemas/providers/usage.d.ts +3 -1
- package/dist/schemas/providers/usage.d.ts.map +1 -1
- package/dist/schemas/providers/usage.js +7 -2
- package/dist/schemas/providers/usage.js.map +1 -1
- package/dist/schemas/run-targets.d.ts.map +1 -1
- package/dist/schemas/run-targets.js +5 -2
- package/dist/schemas/run-targets.js.map +1 -1
- package/dist/schemas/settings-history.d.ts +1 -1
- package/dist/schemas/settings-history.d.ts.map +1 -1
- package/dist/schemas/settings-history.js +21 -0
- package/dist/schemas/settings-history.js.map +1 -1
- package/dist/schemas/settings.d.ts +67 -100
- package/dist/schemas/settings.d.ts.map +1 -1
- package/dist/schemas/settings.js +41 -24
- package/dist/schemas/settings.js.map +1 -1
- package/dist/schemas/short-codes.d.ts +31 -0
- package/dist/schemas/short-codes.d.ts.map +1 -0
- package/dist/schemas/short-codes.js +28 -0
- package/dist/schemas/short-codes.js.map +1 -0
- package/dist/schemas/system.d.ts +130 -0
- package/dist/schemas/system.d.ts.map +1 -1
- package/dist/schemas/system.js +37 -1
- package/dist/schemas/system.js.map +1 -1
- package/dist/state/definition.d.ts +64 -40
- package/dist/state/definition.d.ts.map +1 -1
- package/dist/state/history-state.d.ts.map +1 -1
- package/dist/state/history-state.js +1 -0
- package/dist/state/history-state.js.map +1 -1
- package/dist/state/runtime-state.d.ts +3 -0
- package/dist/state/runtime-state.d.ts.map +1 -1
- package/dist/state/runtime-state.js +1 -0
- package/dist/state/runtime-state.js.map +1 -1
- package/dist/state/versions.d.ts +6 -0
- package/dist/state/versions.d.ts.map +1 -1
- package/dist/state/versions.js +47 -0
- package/dist/state/versions.js.map +1 -1
- package/dist/text/embed.d.ts +2 -1
- package/dist/text/embed.d.ts.map +1 -1
- package/dist/text/embed.js +10 -5
- package/dist/text/embed.js.map +1 -1
- package/dist/text/title.d.ts.map +1 -1
- package/dist/text/title.js +4 -7
- package/dist/text/title.js.map +1 -1
- package/dist/text/transcript-fold.d.ts +2 -1
- package/dist/text/transcript-fold.d.ts.map +1 -1
- package/dist/text/transcript-fold.js +21 -6
- package/dist/text/transcript-fold.js.map +1 -1
- package/package.json +28 -6
- package/src/chores/probes.ts +3 -1
- package/src/contracts/agent.contract.ts +24 -3
- package/src/contracts/offload.contract.ts +21 -1
- package/src/contracts/privacy.contract.ts +13 -0
- package/src/contracts/settings.contract.ts +23 -0
- package/src/events/agent-events.ts +13 -0
- package/src/events/agent-words.ts +2 -3
- package/src/events/resume.test.ts +14 -2
- package/src/events/resume.ts +32 -3
- package/src/events/sandbox-notice.ts +31 -1
- package/src/events/transcript.ts +27 -2
- package/src/index.ts +5 -0
- package/src/models/agent-catalog.test.ts +4 -3
- package/src/models/agent-catalog.ts +10 -7
- package/src/models/agent-runtimes.ts +30 -1
- package/src/models/capability-ledger.test.ts +4 -0
- package/src/models/model-order.ts +13 -0
- package/src/models/model-pins.ts +5 -4
- package/src/models/model-roles.test.ts +9 -31
- package/src/models/model-roles.ts +35 -50
- package/src/models/model-setups.test.ts +100 -0
- package/src/models/model-setups.ts +151 -0
- package/src/models/plan-pools.test.ts +58 -0
- package/src/models/plan-pools.ts +38 -7
- package/src/models/provider-specs.ts +20 -2
- package/src/policy/command-classes.ts +34 -3
- package/src/policy/credential-material.ts +58 -16
- package/src/policy/search-globs.ts +135 -0
- package/src/policy/secret-hosts.ts +7 -0
- package/src/policy/tests/command-classes.test.ts +35 -0
- package/src/policy/tests/credential-material.test.ts +20 -0
- package/src/policy/tests/search-globs.test.ts +46 -1
- package/src/policy/tests/secret-hosts.test.ts +17 -1
- package/src/policy/turned-away.ts +2 -0
- package/src/protocol/doors/runner-protocol.ts +14 -1
- package/src/protocol/pairing-redeem.test.ts +86 -0
- package/src/protocol/pairing-redeem.ts +79 -0
- package/src/protocol/peer-mcp-server.ts +1 -2
- package/src/protocol/raw/raw-json-routes.ts +3 -0
- package/src/protocol/raw/raw-routes.ts +5 -0
- package/src/protocol/sse.test.ts +50 -1
- package/src/protocol/sse.ts +172 -26
- package/src/schemas/agent.ts +23 -2
- package/src/schemas/automations.ts +6 -2
- package/src/schemas/daemon-health.test.ts +28 -0
- package/src/schemas/devices.ts +11 -2
- package/src/schemas/engines.ts +1 -1
- package/src/schemas/git/git-scope.test.ts +91 -0
- package/src/schemas/git/git-scope.ts +71 -0
- package/src/schemas/git/git.ts +15 -1
- package/src/schemas/hosts.ts +7 -0
- package/src/schemas/keep-warm.ts +2 -1
- package/src/schemas/logs.ts +25 -0
- package/src/schemas/metrics.test.ts +80 -0
- package/src/schemas/metrics.ts +31 -9
- package/src/schemas/needs.ts +29 -3
- package/src/schemas/offload.ts +76 -0
- package/src/schemas/privacy.ts +27 -3
- package/src/schemas/project-bring-back.test.ts +15 -0
- package/src/schemas/project-bring-back.ts +78 -0
- package/src/schemas/providers/handoff.test.ts +42 -0
- package/src/schemas/providers/handoff.ts +128 -0
- package/src/schemas/providers/plan-limits.ts +14 -1
- package/src/schemas/providers/usage.ts +13 -7
- package/src/schemas/run-targets.ts +5 -2
- package/src/schemas/settings-history.ts +28 -0
- package/src/schemas/settings.test.ts +74 -21
- package/src/schemas/settings.ts +74 -44
- package/src/schemas/short-codes.ts +48 -0
- package/src/schemas/system.ts +63 -1
- package/src/state/history-state.ts +4 -0
- package/src/state/runtime-state.test.ts +4 -0
- package/src/state/runtime-state.ts +4 -0
- package/src/state/versions.test.ts +30 -1
- package/src/state/versions.ts +68 -0
- package/src/text/embed.test.ts +14 -0
- package/src/text/embed.ts +19 -10
- package/src/text/title.ts +5 -9
- package/src/text/transcript-fold.test.ts +61 -0
- package/src/text/transcript-fold.ts +31 -6
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hostAllowed, hostMatches, normalizeHost, normalizeHostPattern, tightensHostGuard } from "../secret-hosts.js";
|
|
1
|
+
import { hostAllowed, hostCovers, hostMatches, normalizeHost, normalizeHostPattern, tightensHostGuard } from "../secret-hosts.js";
|
|
2
2
|
|
|
3
3
|
// The pattern a person writes against the host a use would reach. Every boundary a limit turns on is pinned by value:
|
|
4
4
|
// the apex a wildcard leaves out, the look-alike that only shares a suffix, and which edits take hosts away.
|
|
@@ -87,3 +87,19 @@ describe("tightensHostGuard", () => {
|
|
|
87
87
|
expect(tightensHostGuard(on("api.github.com"), on("*.api.github.com"))).toBe(false);
|
|
88
88
|
});
|
|
89
89
|
});
|
|
90
|
+
|
|
91
|
+
describe("hostCovers", () => {
|
|
92
|
+
it("reads a plain entry as a domain and every host under it, never a lookalike", () => {
|
|
93
|
+
expect(hostCovers("example.com", "example.com")).toBe(true);
|
|
94
|
+
expect(hostCovers("example.com", "api.example.com")).toBe(true);
|
|
95
|
+
expect(hostCovers("example.com", "a.b.example.com")).toBe(true);
|
|
96
|
+
expect(hostCovers("example.com", "notexample.com")).toBe(false);
|
|
97
|
+
expect(hostCovers("example.com", "example.com.evil.net")).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("reads a wildcard as the hosts under it only", () => {
|
|
101
|
+
expect(hostCovers("*.example.com", "api.example.com")).toBe(true);
|
|
102
|
+
expect(hostCovers("*.example.com", "example.com")).toBe(false);
|
|
103
|
+
expect(hostCovers("*.example.com", "notexample.com")).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -13,6 +13,8 @@ const TURNED_AWAY: ReadonlySet<string> = new Set([
|
|
|
13
13
|
"model-helper-only",
|
|
14
14
|
// The privacy shield refused an untrusted provider on a runtime it cannot read by content at all.
|
|
15
15
|
"privacy-unshielded",
|
|
16
|
+
// The turn carries a tool list its runtime cannot keep.
|
|
17
|
+
"tools-unenforced",
|
|
16
18
|
// The protected execution boundary refused admission, not an executed provider/runtime failure.
|
|
17
19
|
"agent-domain-refused",
|
|
18
20
|
// The privacy shield found personal data in instructions a runtime loads itself, where it could not mask them.
|
|
@@ -20,6 +20,10 @@ export const RunnerHelloSchema = z.object({
|
|
|
20
20
|
// Compared against what the parent runs to report parity (outdated → rebuild action); an outdated runner still runs
|
|
21
21
|
// turns rather than being refused.
|
|
22
22
|
image: z.string(),
|
|
23
|
+
// The image `image` was composed from (SANDBOX_BASE_IMAGE). Parity compares this, not `image`: an overlay build's
|
|
24
|
+
// tag names its own container (`intentic-sandbox-env-<slug>:<hash>`), so two sides on one base and one overlay
|
|
25
|
+
// never share it. Absent on an old image, which is then compared by `image` alone.
|
|
26
|
+
baseImage: z.string().optional(),
|
|
23
27
|
channel: z.string().optional(),
|
|
24
28
|
overlayHash: z.string().optional(),
|
|
25
29
|
// Runner's declared settings-only sandbox.toml (definition.ts), diffed against the parent's to itemize drift;
|
|
@@ -106,13 +110,22 @@ export type RunnerCredentialRefreshRequest = z.infer<typeof RunnerCredentialRefr
|
|
|
106
110
|
export const RunnerCredentialRefreshSchema = z.object({ accessToken: z.string().optional() });
|
|
107
111
|
export type RunnerCredentialRefresh = z.infer<typeof RunnerCredentialRefreshSchema>;
|
|
108
112
|
|
|
109
|
-
// What a runner is, hardware-wise: what the placement picker shows and
|
|
113
|
+
// What a runner is, hardware-wise, and how full it is right now: what the placement picker shows and the fleet advice
|
|
114
|
+
// (runners/fleet-advice.ts) weighs. Re-asked on demand rather than only at connect, so the live half stays live.
|
|
110
115
|
export const RunnerFactsSchema = z.object({
|
|
111
116
|
cpus: z.number().int().positive(),
|
|
112
117
|
memoryMb: z.number().int().positive(),
|
|
113
118
|
freeDiskMb: z.number().int().nonnegative(),
|
|
114
119
|
// 0..1 over the last minute; a coarse busy signal for a picker, not a metrics feed.
|
|
115
120
|
load: z.number().nonnegative(),
|
|
121
|
+
// The runner's own resource budget, the figures its turn door admits by: what is free under its memory limit, and
|
|
122
|
+
// that limit. Absent from a runner that predates them, which is then sized by `memoryMb` alone.
|
|
123
|
+
memoryFreeMb: z.number().int().nonnegative().optional(),
|
|
124
|
+
memoryLimitMb: z.number().int().positive().optional(),
|
|
125
|
+
// Pressure stall over the last ten seconds, in percent: anything waiting for a CPU, everything waiting on memory.
|
|
126
|
+
// Absent where the kernel does not report it.
|
|
127
|
+
cpuPressure: z.number().nonnegative().optional(),
|
|
128
|
+
memoryStall: z.number().nonnegative().optional(),
|
|
116
129
|
});
|
|
117
130
|
export type RunnerFacts = z.infer<typeof RunnerFactsSchema>;
|
|
118
131
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { redeemWhileWarming, type RedeemRetry } from "./pairing-redeem.js";
|
|
2
|
+
|
|
3
|
+
/* The redeem loop both pairing peers run, over a sandbox the test plays one answer at a time. */
|
|
4
|
+
|
|
5
|
+
const scripted = (answers: readonly (number | "offline" | "hang")[]) => {
|
|
6
|
+
const tries: number[] = [];
|
|
7
|
+
const send = async (attempt: number, signal: AbortSignal | undefined): Promise<Response> => {
|
|
8
|
+
tries.push(attempt);
|
|
9
|
+
const next = answers[attempt - 1] ?? answers.at(-1) ?? "offline";
|
|
10
|
+
if (next === "offline") {
|
|
11
|
+
throw new TypeError("fetch failed");
|
|
12
|
+
}
|
|
13
|
+
if (next === "hang") {
|
|
14
|
+
return new Promise<Response>((_resolve, reject) => signal?.addEventListener("abort", () => reject(signal.reason)));
|
|
15
|
+
}
|
|
16
|
+
return new Response(`status ${next}`, { status: next });
|
|
17
|
+
};
|
|
18
|
+
return { tries, send };
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const statusOf = (outcome: Awaited<ReturnType<typeof redeemWhileWarming>>): number | undefined =>
|
|
22
|
+
outcome.kind === "answered" ? outcome.response.status : undefined;
|
|
23
|
+
|
|
24
|
+
describe("redeemWhileWarming", () => {
|
|
25
|
+
it("answers the first reply below 500 without asking again", async () => {
|
|
26
|
+
const { tries, send } = scripted([200]);
|
|
27
|
+
expect(statusOf(await redeemWhileWarming(send, { attempts: 3, delayMs: 0 }))).toBe(200);
|
|
28
|
+
expect(tries).toEqual([1]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("asks again through a warming sandbox and an unreachable one, saying why each time", async () => {
|
|
32
|
+
const { tries, send } = scripted(["offline", 502, 200]);
|
|
33
|
+
const heard: { attempt: number; why: RedeemRetry["kind"] }[] = [];
|
|
34
|
+
const outcome = await redeemWhileWarming(send, { attempts: 5, delayMs: 0, onRetry: (attempt, why) => heard.push({ attempt, why: why.kind }) });
|
|
35
|
+
expect(statusOf(outcome)).toBe(200);
|
|
36
|
+
expect(tries).toEqual([1, 2, 3]);
|
|
37
|
+
expect(heard).toEqual([
|
|
38
|
+
{ attempt: 1, why: "unreachable" },
|
|
39
|
+
{ attempt: 2, why: "warming" },
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("hands back a 401 at once: an expired code is an answer, not a blip", async () => {
|
|
44
|
+
const { tries, send } = scripted([401, 200]);
|
|
45
|
+
expect(statusOf(await redeemWhileWarming(send, { attempts: 5, delayMs: 0 }))).toBe(401);
|
|
46
|
+
expect(tries).toEqual([1]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("answers the last 5xx when the sandbox is still warming after every try", async () => {
|
|
50
|
+
const { tries, send } = scripted([503]);
|
|
51
|
+
const heard: number[] = [];
|
|
52
|
+
const outcome = await redeemWhileWarming(send, { attempts: 3, delayMs: 0, onRetry: (attempt) => heard.push(attempt) });
|
|
53
|
+
expect(outcome.kind).toBe("answered");
|
|
54
|
+
expect(outcome.kind === "answered" ? await outcome.response.text() : undefined).toBe("status 503");
|
|
55
|
+
expect(tries).toEqual([1, 2, 3]);
|
|
56
|
+
// The last try is not retried, so it is not narrated as one.
|
|
57
|
+
expect(heard).toEqual([1, 2]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("is unreachable, with the last try's failure, when nothing ever answered", async () => {
|
|
61
|
+
const { tries, send } = scripted(["offline"]);
|
|
62
|
+
const outcome = await redeemWhileWarming(send, { attempts: 2, delayMs: 0 });
|
|
63
|
+
expect(outcome.kind).toBe("unreachable");
|
|
64
|
+
expect(outcome.kind === "unreachable" ? outcome.error : undefined).toBeInstanceOf(TypeError);
|
|
65
|
+
expect(tries).toEqual([1, 2]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("abandons a try that outlives its timeout and asks again", async () => {
|
|
69
|
+
const { tries, send } = scripted(["hang", 200]);
|
|
70
|
+
expect(statusOf(await redeemWhileWarming(send, { attempts: 2, delayMs: 0, timeoutMs: 10 }))).toBe(200);
|
|
71
|
+
expect(tries).toEqual([1, 2]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("rejects with the stop's reason rather than answering when its signal aborts", async () => {
|
|
75
|
+
const stop = new AbortController();
|
|
76
|
+
const { tries, send } = scripted([502]);
|
|
77
|
+
const running = redeemWhileWarming(send, {
|
|
78
|
+
attempts: 5,
|
|
79
|
+
delayMs: 60_000,
|
|
80
|
+
signal: stop.signal,
|
|
81
|
+
onRetry: () => stop.abort(new Error("stopped")),
|
|
82
|
+
});
|
|
83
|
+
await expect(running).rejects.toThrow("stopped");
|
|
84
|
+
expect(tries).toEqual([1]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { retry, withDeadline } from "@intentic/base/async";
|
|
2
|
+
|
|
3
|
+
// Redeeming a pairing code's one-time token, as every peer that takes one does it (the machine agent's
|
|
4
|
+
// `postWhileWarming`, the webext's `redeemPairing`): a sandbox still warming up answers 5xx through its tunnel, or
|
|
5
|
+
// nothing at all, and is asked again. Any other answer, a 401 above all (the code has expired), is the caller's to read
|
|
6
|
+
// at once: asking again would only delay the fresh code the person has to fetch.
|
|
7
|
+
|
|
8
|
+
// Why a try is being asked again: no answer at all, or a 5xx from a sandbox not ready yet.
|
|
9
|
+
export type RedeemRetry = { readonly kind: "unreachable"; readonly error: unknown } | { readonly kind: "warming"; readonly status: number };
|
|
10
|
+
|
|
11
|
+
export type RedeemOutcome =
|
|
12
|
+
// The sandbox's answer: below 500 at once, or still 5xx when the tries ran out.
|
|
13
|
+
| { readonly kind: "answered"; readonly response: Response }
|
|
14
|
+
// No answer at all on the last try; `error` is that try's failure.
|
|
15
|
+
| { readonly kind: "unreachable"; readonly error: unknown };
|
|
16
|
+
|
|
17
|
+
export interface RedeemOptions {
|
|
18
|
+
// Tries in all, the first included.
|
|
19
|
+
readonly attempts: number;
|
|
20
|
+
// The wait between tries.
|
|
21
|
+
readonly delayMs: number;
|
|
22
|
+
// How long one try may take before it is abandoned and counts as unreachable; none when absent.
|
|
23
|
+
readonly timeoutMs?: number | undefined;
|
|
24
|
+
// Stops the tries: a stop rejects with the signal's reason rather than answering.
|
|
25
|
+
readonly signal?: AbortSignal | undefined;
|
|
26
|
+
// Hears each try that is about to be asked again, before the wait.
|
|
27
|
+
readonly onRetry?: ((attempt: number, why: RedeemRetry) => void) | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A 5xx carried out of a try so `retry` asks again, and handed back as the answer when the tries run out.
|
|
31
|
+
class Warming extends Error {
|
|
32
|
+
constructor(readonly response: Response) {
|
|
33
|
+
super(`the sandbox is warming up (HTTP ${response.status})`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Runs `send` (one POST, handed the try's number and a signal to pass to its fetch, undefined when there is neither a
|
|
38
|
+
// deadline nor a stop) until the sandbox answers below 500 or the tries run out.
|
|
39
|
+
export const redeemWhileWarming = async (
|
|
40
|
+
send: (attempt: number, signal: AbortSignal | undefined) => Promise<Response>,
|
|
41
|
+
options: RedeemOptions,
|
|
42
|
+
): Promise<RedeemOutcome> => {
|
|
43
|
+
const { attempts, delayMs, timeoutMs, signal, onRetry } = options;
|
|
44
|
+
const once = (attempt: number): Promise<Response> =>
|
|
45
|
+
timeoutMs === undefined ? send(attempt, signal) : withDeadline((bounded) => send(attempt, bounded), timeoutMs, { signal });
|
|
46
|
+
try {
|
|
47
|
+
const response = await retry(
|
|
48
|
+
async (attempt) => {
|
|
49
|
+
let answer: Response;
|
|
50
|
+
try {
|
|
51
|
+
answer = await once(attempt);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (attempt < attempts && signal?.aborted !== true) {
|
|
54
|
+
onRetry?.(attempt, { kind: "unreachable", error });
|
|
55
|
+
}
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
if (answer.status < 500) {
|
|
59
|
+
return answer;
|
|
60
|
+
}
|
|
61
|
+
if (attempt < attempts) {
|
|
62
|
+
onRetry?.(attempt, { kind: "warming", status: answer.status });
|
|
63
|
+
}
|
|
64
|
+
throw new Warming(answer);
|
|
65
|
+
},
|
|
66
|
+
{ attempts, delayMs: () => delayMs, signal },
|
|
67
|
+
);
|
|
68
|
+
return { kind: "answered", response };
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (signal?.aborted === true) {
|
|
71
|
+
// oxlint-disable-next-line typescript/only-throw-error -- the stop's own reason, as an aborted fetch rejects with it
|
|
72
|
+
throw signal.reason;
|
|
73
|
+
}
|
|
74
|
+
if (error instanceof Warming) {
|
|
75
|
+
return { kind: "answered", response: error.response };
|
|
76
|
+
}
|
|
77
|
+
return { kind: "unreachable", error };
|
|
78
|
+
}
|
|
79
|
+
};
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// namespace keeps zod's 60 locales (~250 kB) that esbuild can otherwise drop. _devices/webext/scripts/size-budget.mjs holds the ceiling.
|
|
3
3
|
import { prettifyError, toJSONSchema } from "zod";
|
|
4
4
|
import type * as z from "zod";
|
|
5
|
+
import { isRecord } from "@intentic/base/escape";
|
|
5
6
|
|
|
6
7
|
// The MCP version every peer announces, here rather than in host-protocol.ts because a browser extension bundles this
|
|
7
8
|
// module and would otherwise carry the machine handshake's schemas to a store. Also read by the daemon's peer bridge,
|
|
@@ -99,8 +100,6 @@ export interface McpServerSpec<Ctx> {
|
|
|
99
100
|
readonly audit: (entry: McpAuditEntry) => Promise<void> | void;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
103
|
-
|
|
104
103
|
// Handles one JSON-RPC message; returns undefined for a notification. `ctx` is read per call, so a grant pushed
|
|
105
104
|
// mid-session takes effect on the next call.
|
|
106
105
|
export const createMcpServer = <Ctx>(spec: McpServerSpec<Ctx>): ((message: unknown, ctx: Ctx) => Promise<Record<string, unknown> | undefined>) => {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "../../schemas/environment.js";
|
|
11
11
|
import { MemberEmailSchema, MemberGrantSchema, MembersListSchema, MembersRosterSchema } from "../../schemas/members.js";
|
|
12
12
|
import { OkSchema } from "../../schemas/shared.js";
|
|
13
|
+
import { EnrollCodeAnswerSchema, EnrollCodeRequestSchema } from "../../schemas/short-codes.js";
|
|
13
14
|
import { DefinitionDiffSchema, DefinitionExportSchema, WorkspacePublishResultSchema, WorkspacePublishSchema, WorkspaceRemoteSchema } from "../../state/definition.js";
|
|
14
15
|
import type { RawRouteKey } from "./raw-routes.js";
|
|
15
16
|
|
|
@@ -24,6 +25,8 @@ export interface RawJsonRoute {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export const RAW_JSON_ROUTES = {
|
|
28
|
+
// A server-connect code for the owner's command (short-codes.ts).
|
|
29
|
+
"POST /system/enroll/code": { input: EnrollCodeRequestSchema, output: EnrollCodeAnswerSchema },
|
|
27
30
|
// The roster is ownership's; every write answers the roster after it.
|
|
28
31
|
"GET /members": { output: MembersRosterSchema },
|
|
29
32
|
"POST /members": { input: MemberGrantSchema, output: MembersListSchema },
|
|
@@ -49,6 +49,8 @@ export const RAW_ROUTES = {
|
|
|
49
49
|
"GET /system/desktop-view": { auth: "door", beforeBoot: true, control: "never" },
|
|
50
50
|
// Deploy-target enrollment, gated by the connect token.
|
|
51
51
|
"POST /enroll": { auth: "door", control: "never" },
|
|
52
|
+
// A server-connect code for the owner's command: its single-use enroll token stands in for the connect token.
|
|
53
|
+
"POST /system/enroll/code": { floor: "owner", control: "never" },
|
|
52
54
|
// An event automation's webhook, gated by the automation's own token.
|
|
53
55
|
"POST /automations/{id}/fire": { auth: "door" },
|
|
54
56
|
// The release gate a pipeline runner waits on, gated by the workflow's minted gate token.
|
|
@@ -154,6 +156,9 @@ export const RAW_ROUTES = {
|
|
|
154
156
|
"POST /ci/webhook/{host}": { auth: "door" },
|
|
155
157
|
// Below maintainer a pairing is capped to port-mirror, so a collaborator may mint a preview tunnel.
|
|
156
158
|
"POST /system/sync/pair": { floor: "collaborator", control: "never" },
|
|
159
|
+
// A copy-paste command's short code, redeemed by the install script it was handed to: a device's first contact,
|
|
160
|
+
// gated by the code itself and a limit on failed attempts (peers/redeem-codes.ts).
|
|
161
|
+
"POST /system/redeem/{code}": { auth: "door", control: "never" },
|
|
157
162
|
// The peer doors: a device, browser or runner redeems its pairing at `enroll` and dials `connect` with the token.
|
|
158
163
|
"POST /system/hosts/pair": { control: "never" },
|
|
159
164
|
"POST /system/hosts/enroll": { auth: "door", control: "never" },
|
package/src/protocol/sse.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sseData, sseFrames } from "./sse.js";
|
|
1
|
+
import { parseSseText, type SseEvent, sseData, sseEvents, sseFrames, sseJson } from "./sse.js";
|
|
2
2
|
|
|
3
3
|
const encoder = new TextEncoder();
|
|
4
4
|
|
|
@@ -47,3 +47,52 @@ it("reads a frame's data line as JSON, and nothing from a frame without one or w
|
|
|
47
47
|
expect(sseData(`: keep-alive`)).toBeUndefined();
|
|
48
48
|
expect(sseData(`data: {not json`)).toBeUndefined();
|
|
49
49
|
});
|
|
50
|
+
|
|
51
|
+
const collectEvents = async (events: AsyncGenerator<SseEvent>): Promise<SseEvent[]> => {
|
|
52
|
+
const seen: SseEvent[] = [];
|
|
53
|
+
for await (const event of events) {
|
|
54
|
+
seen.push(event);
|
|
55
|
+
}
|
|
56
|
+
return seen;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
it("ends events at CRLF, CR and LF blank lines alike, a CRLF split across chunks included", async () => {
|
|
60
|
+
const { stream } = body([`data: 1\r\n\r`, `\ndata: 2\r\rdata: 3\n\n`], true);
|
|
61
|
+
expect((await collectEvents(sseEvents(stream))).map((event) => event.data)).toEqual([`1`, `2`, `3`]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("joins every data line of an event with a newline", () => {
|
|
65
|
+
expect(parseSseText(`data: {"a":\ndata: 1}\n\n`).map((event) => event.data)).toEqual([`{"a":\n1}`]);
|
|
66
|
+
expect(sseData(`data: {"a":\ndata: 1}`)).toEqual({ a: 1 });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("reads event, id and retry, keeps the last id across events, and skips comments", () => {
|
|
70
|
+
const events = parseSseText(`: hello\nevent: turn\nid: 7\nretry: 3000\ndata: x\n\ndata: y\n\n`);
|
|
71
|
+
expect(events.map(({ event, data, id, retry }) => ({ event, data, id, retry }))).toEqual([
|
|
72
|
+
{ event: `turn`, data: `x`, id: `7`, retry: 3000 },
|
|
73
|
+
{ event: undefined, data: `y`, id: `7`, retry: undefined },
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("drops one space after the colon and no more, and a leading byte-order mark", () => {
|
|
78
|
+
expect(parseSseText(`\uFEFFdata: two spaces\n\n`).map((event) => event.data)).toEqual([` two spaces`]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("keeps each event's raw text, its blank line included, for a reader forwarding it unchanged", () => {
|
|
82
|
+
expect(parseSseText(`event: a\r\ndata: 1\r\n\r\n: ping\n\n`).map((event) => event.raw)).toEqual([`event: a\r\ndata: 1\r\n\r\n`, `: ping\n\n`]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("dispatches an event the stream ended inside", async () => {
|
|
86
|
+
const { stream } = body([`data: last`], true);
|
|
87
|
+
expect((await collectEvents(sseEvents(stream))).map((event) => event.data)).toEqual([`last`]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("ends an event stream that goes silent past the idle window", async () => {
|
|
91
|
+
const { stream, cancelled } = body([`data: 1\n\n`], false);
|
|
92
|
+
expect((await collectEvents(sseEvents(stream, 20))).map((event) => event.data)).toEqual([`1`]);
|
|
93
|
+
expect(cancelled()).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("sseJson reads a parsed event's data", () => {
|
|
97
|
+
expect([sseJson(parseSseText(`data: [1]\n\n`)[0]!), sseJson(parseSseText(`: c\n\n`)[0]!)]).toEqual([[1], undefined]);
|
|
98
|
+
});
|
package/src/protocol/sse.ts
CHANGED
|
@@ -1,36 +1,166 @@
|
|
|
1
|
-
//
|
|
2
|
-
// a
|
|
3
|
-
//
|
|
1
|
+
// Server-Sent Events as the WHATWG event-stream grammar defines them, read off a byte stream that may be cut anywhere:
|
|
2
|
+
// inside a UTF-8 character, inside a `\r\n`, inside an event. Protocol-only (no domain shapes). Its readers: the ACP
|
|
3
|
+
// bridge and the extension SDK on the daemon's streamed routes, the privacy gateway's restorers, and the daemon's own
|
|
4
|
+
// MCP calls (an app's tool calls, a capability probe's `initialize`). Each had a parser of its own, and they disagreed:
|
|
5
|
+
// this one used to split on `\n\n` only and read the first `data:` line of an event, where the grammar allows `\r\n`
|
|
6
|
+
// and `\r` and joins every data line with `\n`. The webchat widget reads its replies with it too, through the `./sse`
|
|
7
|
+
// subpath: a bundle on customers' pages may take only modules with no imports, as this one is and must stay.
|
|
4
8
|
|
|
5
9
|
// A live daemon writes to a stream at least this often (oRPC's keep-alive comment every 5 s, a heartbeat on the rest), so
|
|
6
10
|
// this long with no bytes means the connection is dead even though nothing closed it.
|
|
7
11
|
export const SSE_IDLE_MS = 120_000;
|
|
8
12
|
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
export interface SseEvent {
|
|
14
|
+
// The `event:` field, undefined when the event named none (a listener reads that as "message").
|
|
15
|
+
readonly event: string | undefined;
|
|
16
|
+
// The data lines joined with `\n`; undefined for a block with none (a comment, a keep-alive, a stray blank line).
|
|
17
|
+
readonly data: string | undefined;
|
|
18
|
+
// The last event id the stream has set, as EventSource carries it: it persists across events until set again.
|
|
19
|
+
readonly id: string | undefined;
|
|
20
|
+
// The reconnection time an event asked for, in milliseconds.
|
|
21
|
+
readonly retry: number | undefined;
|
|
22
|
+
// The event's own text as it arrived, its terminating blank line included, for a reader that forwards it unchanged.
|
|
23
|
+
readonly raw: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SseParser {
|
|
27
|
+
readonly feed: (text: string) => void;
|
|
28
|
+
// The stream ended: an event it ended inside is dispatched as it stands.
|
|
29
|
+
readonly end: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Pending {
|
|
33
|
+
raw: string;
|
|
34
|
+
name: string | undefined;
|
|
35
|
+
data: string[] | undefined;
|
|
36
|
+
retry: number | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const fresh = (): Pending => ({ raw: ``, name: undefined, data: undefined, retry: undefined });
|
|
40
|
+
|
|
41
|
+
// Fields per the grammar: `field: value` (one leading space dropped), `:` starts a comment, a blank line ends the event.
|
|
42
|
+
// Every block is dispatched, a comment-only one too, so a reader forwarding the stream loses nothing; a reader of
|
|
43
|
+
// messages skips the ones whose `data` is undefined.
|
|
44
|
+
export const createSseParser = (dispatch: (event: SseEvent) => void): SseParser => {
|
|
45
|
+
let buffer = ``;
|
|
46
|
+
// How far into `buffer` is known to hold no terminator, so a long line arriving in small chunks is scanned once.
|
|
47
|
+
let scanned = 0;
|
|
48
|
+
let started = false;
|
|
49
|
+
let lastId: string | undefined;
|
|
50
|
+
let pending = fresh();
|
|
51
|
+
|
|
52
|
+
const emit = (): void => {
|
|
53
|
+
dispatch({ event: pending.name, data: pending.data?.join(`\n`), id: lastId, retry: pending.retry, raw: pending.raw });
|
|
54
|
+
pending = fresh();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const field = (name: string, value: string): void => {
|
|
58
|
+
if (name === `event`) {
|
|
59
|
+
pending.name = value;
|
|
60
|
+
} else if (name === `data`) {
|
|
61
|
+
pending.data = [...(pending.data ?? []), value];
|
|
62
|
+
} else if (name === `id` && !value.includes(`\0`)) {
|
|
63
|
+
lastId = value;
|
|
64
|
+
} else if (name === `retry` && /^\d+$/.test(value)) {
|
|
65
|
+
pending.retry = Number(value);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const line = (text: string, terminator: string): void => {
|
|
70
|
+
pending.raw += text + terminator;
|
|
71
|
+
if (text === ``) {
|
|
72
|
+
emit();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (text.startsWith(`:`)) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const colon = text.indexOf(`:`);
|
|
79
|
+
field(colon < 0 ? text : text.slice(0, colon), colon < 0 ? `` : text.slice(colon + (text.charAt(colon + 1) === ` ` ? 2 : 1)));
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const lines = (final: boolean): void => {
|
|
83
|
+
const terminators = /\r\n|\r|\n/gu;
|
|
84
|
+
terminators.lastIndex = scanned;
|
|
85
|
+
let start = 0;
|
|
86
|
+
for (let found = terminators.exec(buffer); found !== null; found = terminators.exec(buffer)) {
|
|
87
|
+
// A `\r` that ends the buffer may be the first half of a `\r\n` still in flight.
|
|
88
|
+
if (found[0] === `\r` && found.index === buffer.length - 1 && !final) {
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
line(buffer.slice(start, found.index), found[0]);
|
|
92
|
+
start = found.index + found[0].length;
|
|
93
|
+
}
|
|
94
|
+
buffer = buffer.slice(start);
|
|
95
|
+
scanned = Math.max(0, buffer.length - 1);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
feed: (text) => {
|
|
100
|
+
let next = text;
|
|
101
|
+
// The grammar drops one byte-order mark at the very start; it stays in the first event's raw text.
|
|
102
|
+
if (!started && next !== ``) {
|
|
103
|
+
started = true;
|
|
104
|
+
if (next.startsWith(`\uFEFF`)) {
|
|
105
|
+
pending.raw += `\uFEFF`;
|
|
106
|
+
next = next.slice(1);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
buffer += next;
|
|
110
|
+
lines(false);
|
|
111
|
+
},
|
|
112
|
+
end: () => {
|
|
113
|
+
lines(true);
|
|
114
|
+
if (buffer !== ``) {
|
|
115
|
+
line(buffer, ``);
|
|
116
|
+
buffer = ``;
|
|
117
|
+
}
|
|
118
|
+
if (pending.raw !== ``) {
|
|
119
|
+
emit();
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Every event in a body already read whole (a short MCP answer), the last one dispatched even without its blank line.
|
|
126
|
+
export const parseSseText = (text: string): SseEvent[] => {
|
|
127
|
+
const events: SseEvent[] = [];
|
|
128
|
+
const parser = createSseParser((event) => events.push(event));
|
|
129
|
+
parser.feed(text);
|
|
130
|
+
parser.end();
|
|
131
|
+
return events;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Yields each event as it arrives. Ends, cancelling the reader, after `idleMs` of silence, so a reader of a daemon that
|
|
135
|
+
// went quiet without closing (a sleeping laptop's tunnel, a wedged proxy) finishes instead of hanging.
|
|
136
|
+
export async function* sseEvents(body: ReadableStream<Uint8Array>, idleMs: number = SSE_IDLE_MS): AsyncGenerator<SseEvent> {
|
|
13
137
|
const reader = body.getReader();
|
|
14
138
|
const decoder = new TextDecoder();
|
|
15
|
-
let
|
|
139
|
+
let ready: SseEvent[] = [];
|
|
140
|
+
const parser = createSseParser((event) => ready.push(event));
|
|
16
141
|
try {
|
|
17
142
|
for (;;) {
|
|
18
143
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
19
|
-
const idle = new Promise
|
|
20
|
-
timer = setTimeout(() => resolve(
|
|
144
|
+
const idle = new Promise<`idle`>((resolve) => {
|
|
145
|
+
timer = setTimeout(() => resolve(`idle`), idleMs);
|
|
21
146
|
});
|
|
22
147
|
// oxlint-disable-next-line eslint/no-await-in-loop -- streaming reader; each chunk must be awaited in order as it arrives
|
|
23
148
|
const result = await Promise.race([reader.read(), idle]);
|
|
24
149
|
clearTimeout(timer);
|
|
25
|
-
if (result ===
|
|
150
|
+
if (result === `idle`) {
|
|
26
151
|
return;
|
|
27
152
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
153
|
+
if (result.done) {
|
|
154
|
+
parser.feed(decoder.decode());
|
|
155
|
+
parser.end();
|
|
156
|
+
} else {
|
|
157
|
+
parser.feed(decoder.decode(result.value, { stream: true }));
|
|
158
|
+
}
|
|
159
|
+
const events = ready;
|
|
160
|
+
ready = [];
|
|
161
|
+
yield* events;
|
|
162
|
+
if (result.done) {
|
|
163
|
+
return;
|
|
34
164
|
}
|
|
35
165
|
}
|
|
36
166
|
} finally {
|
|
@@ -39,20 +169,36 @@ export async function* sseFrames(body: ReadableStream<Uint8Array>, idleMs: numbe
|
|
|
39
169
|
}
|
|
40
170
|
}
|
|
41
171
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
export
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
return undefined;
|
|
172
|
+
// Each event's text as it arrived, without the blank line that ended it: for a reader that keeps the frame and reads
|
|
173
|
+
// it with `sseData`. The same idle window as `sseEvents`.
|
|
174
|
+
export async function* sseFrames(body: ReadableStream<Uint8Array>, idleMs: number = SSE_IDLE_MS): AsyncGenerator<string> {
|
|
175
|
+
for await (const event of sseEvents(body, idleMs)) {
|
|
176
|
+
yield event.raw.replace(/(?:\r\n|\r|\n)+$/u, ``);
|
|
48
177
|
}
|
|
49
|
-
|
|
50
|
-
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// What a data payload parses to, before a caller's schema reads it.
|
|
181
|
+
export type SseJsonValue = string | number | boolean | null | readonly SseJsonValue[] | { readonly [key: string]: SseJsonValue };
|
|
182
|
+
|
|
183
|
+
// An event's data as JSON, or undefined for an event with no data, an empty payload, or malformed JSON (all cases
|
|
184
|
+
// the callers skip).
|
|
185
|
+
export const sseJson = (event: SseEvent): SseJsonValue | undefined => {
|
|
186
|
+
if (event.data === undefined || event.data.trim() === ``) {
|
|
51
187
|
return undefined;
|
|
52
188
|
}
|
|
53
189
|
try {
|
|
54
|
-
|
|
190
|
+
// SAFETY: JSON.parse returns only JSON values; each caller's schema reads the shape it expects.
|
|
191
|
+
return JSON.parse(event.data) as SseJsonValue;
|
|
55
192
|
} catch {
|
|
56
193
|
return undefined; // Skip a malformed frame.
|
|
57
194
|
}
|
|
58
195
|
};
|
|
196
|
+
|
|
197
|
+
// The same, for a frame's text as `sseFrames` yields it.
|
|
198
|
+
export const sseData = (frame: string): SseJsonValue | undefined => {
|
|
199
|
+
const event = parseSseText(frame).find((parsed) => parsed.data !== undefined);
|
|
200
|
+
return event === undefined ? undefined : sseJson(event);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// The `event:` name of a frame's text, for a reader holding the frame rather than the parsed event.
|
|
204
|
+
export const sseEventName = (frame: string): string | undefined => parseSseText(frame)[0]?.event;
|
package/src/schemas/agent.ts
CHANGED
|
@@ -128,6 +128,10 @@ export const ForkedFromSchema = z.object({
|
|
|
128
128
|
});
|
|
129
129
|
export type ForkedFrom = z.infer<typeof ForkedFromSchema>;
|
|
130
130
|
// The turn's fields before the cross-field refinements below, so a subset (TurnProfileSchema) can be picked from them.
|
|
131
|
+
// How many files one turn may carry. A batch of a person's queued messages joins their files, so the admission splits a
|
|
132
|
+
// batch rather than build a turn past this (turn-admission.ts, together): a turn the daemon makes must parse back.
|
|
133
|
+
export const ATTACHMENT_LIMIT = 20;
|
|
134
|
+
|
|
131
135
|
const AgentTurnFieldsSchema = z.object({
|
|
132
136
|
prompt: z.string().describe("What to say to the agent. May be empty if you are only attaching files."),
|
|
133
137
|
errand: TurnErrandSchema.optional().describe(
|
|
@@ -151,7 +155,7 @@ const AgentTurnFieldsSchema = z.object({
|
|
|
151
155
|
// Already uploaded via /workspace/upload; Claude reads them via Read, Codex gets images as native input.
|
|
152
156
|
attachments: z
|
|
153
157
|
.array(z.string().min(1))
|
|
154
|
-
.max(
|
|
158
|
+
.max(ATTACHMENT_LIMIT)
|
|
155
159
|
.optional()
|
|
156
160
|
.describe("Files to hand the agent along with the prompt, as workspace paths. Upload them first."),
|
|
157
161
|
// Read out of the prompt's own `@path` tokens rather than chosen: a tokenizer over pasted text guesses, so a
|
|
@@ -450,7 +454,7 @@ export const runPickOf = (choice: {
|
|
|
450
454
|
...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
|
|
451
455
|
...(choice.fast === undefined ? {} : { fast: choice.fast }),
|
|
452
456
|
});
|
|
453
|
-
/* ONE ENTRY OF ONE
|
|
457
|
+
/* ONE ENTRY OF ONE SETUP'S MODEL LIST (settings.modelSetups): the standing version of the pick above, and not merely which model but HOW it is to be run. */
|
|
454
458
|
export const ModelPinSchema = z.object({
|
|
455
459
|
provider: AgentProviderSchema.describe("Which provider serves this work."),
|
|
456
460
|
model: z.string().min(1).describe("Which of its models. Both halves, because a model name only means anything to the provider that serves it."),
|
|
@@ -538,12 +542,29 @@ export const ConversationQueueSchema = z.object({
|
|
|
538
542
|
),
|
|
539
543
|
});
|
|
540
544
|
export type ConversationQueue = z.infer<typeof ConversationQueueSchema>;
|
|
545
|
+
// What taking a waiting message back left: the queue, and whether the conversation went with the message. A conversation
|
|
546
|
+
// its scheduled message opened, in which nothing has happened since, exists only for that message.
|
|
547
|
+
export const QueueTakenBackSchema = ConversationQueueSchema.extend({
|
|
548
|
+
unopened: z
|
|
549
|
+
.boolean()
|
|
550
|
+
.optional()
|
|
551
|
+
.describe(
|
|
552
|
+
"True when the message was the only thing in a conversation it had opened by being scheduled: no turn ran and nothing else waits. The conversation went with it, so its card is off the board and its id is free to open again. Absent otherwise, and from sandboxes older than the field.",
|
|
553
|
+
),
|
|
554
|
+
});
|
|
555
|
+
export type QueueTakenBack = z.infer<typeof QueueTakenBackSchema>;
|
|
541
556
|
// What resuming a queue did: started a turn with what waited, when nothing else ran.
|
|
542
557
|
export const QueueResumedSchema = z.object({
|
|
543
558
|
run: z.string().optional().describe("The turn the waiting messages started. Absent when a turn was already running, and they go after it."),
|
|
544
559
|
queue: ConversationQueueSchema.optional().describe(
|
|
545
560
|
"The queue the release left: what still waits once the turn took what it could. Absent from sandboxes older than the field.",
|
|
546
561
|
),
|
|
562
|
+
reason: z
|
|
563
|
+
.string()
|
|
564
|
+
.optional()
|
|
565
|
+
.describe(
|
|
566
|
+
"Why nothing went out though messages still wait, in a sentence: a spent allowance whose window is shut, a recovery the sandbox runs first, a paused queue. Absent when something went out, when they simply go after a running turn, and from sandboxes older than the field.",
|
|
567
|
+
),
|
|
547
568
|
});
|
|
548
569
|
export type QueueResumed = z.infer<typeof QueueResumedSchema>;
|
|
549
570
|
// Attaches to a conversation's newest run (live, or finished within retention); no cursor to resume from, the head
|