@intentic/sandbox-contract 1.301.0 → 1.303.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contracts/agent.contract.d.ts +6 -9
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.d.ts +193 -433
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.js +6 -24
- package/dist/contracts/agents.contract.js.map +1 -1
- package/dist/contracts/areas.contract.d.ts +24 -0
- package/dist/contracts/areas.contract.d.ts.map +1 -0
- package/dist/contracts/areas.contract.js +32 -0
- package/dist/contracts/areas.contract.js.map +1 -0
- package/dist/contracts/automations.contract.d.ts +3 -0
- package/dist/contracts/automations.contract.d.ts.map +1 -1
- package/dist/contracts/capabilities.contract.d.ts +0 -46
- package/dist/contracts/capabilities.contract.d.ts.map +1 -1
- package/dist/contracts/endpoints.contract.d.ts +132 -0
- package/dist/contracts/endpoints.contract.d.ts.map +1 -1
- package/dist/contracts/endpoints.contract.js +50 -0
- package/dist/contracts/endpoints.contract.js.map +1 -1
- package/dist/contracts/providers.contract.d.ts +21 -0
- package/dist/contracts/providers.contract.d.ts.map +1 -1
- package/dist/contracts/providers.contract.js +1 -0
- package/dist/contracts/providers.contract.js.map +1 -1
- package/dist/contracts/runner.contract.d.ts +98 -95
- package/dist/contracts/runner.contract.d.ts.map +1 -1
- package/dist/contracts/sessions.contract.d.ts +0 -1
- package/dist/contracts/sessions.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.d.ts +110 -6
- 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 +23 -12
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/events/agent-events.d.ts +1 -3
- package/dist/events/agent-events.d.ts.map +1 -1
- package/dist/events/agent-events.js +1 -0
- package/dist/events/agent-events.js.map +1 -1
- package/dist/events/system-events.d.ts +32 -6
- package/dist/events/system-events.d.ts.map +1 -1
- package/dist/events/transcript.d.ts +2 -6
- package/dist/events/transcript.d.ts.map +1 -1
- package/dist/events/transcript.js +5 -1
- package/dist/events/transcript.js.map +1 -1
- package/dist/index.d.ts +481 -549
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/models/agent-catalog.d.ts.map +1 -1
- package/dist/models/agent-catalog.js +1 -1
- package/dist/models/agent-catalog.js.map +1 -1
- package/dist/policy/fence-paths.d.ts +10 -0
- package/dist/policy/fence-paths.d.ts.map +1 -0
- package/dist/policy/fence-paths.js +58 -0
- package/dist/policy/fence-paths.js.map +1 -0
- package/dist/protocol/routes.d.ts.map +1 -1
- package/dist/protocol/routes.js +4 -1
- package/dist/protocol/routes.js.map +1 -1
- package/dist/schemas/agents.d.ts +51 -19
- package/dist/schemas/agents.d.ts.map +1 -1
- package/dist/schemas/agents.js +7 -22
- package/dist/schemas/agents.js.map +1 -1
- package/dist/schemas/areas.d.ts +21 -0
- package/dist/schemas/areas.d.ts.map +1 -0
- package/dist/schemas/areas.js +32 -0
- package/dist/schemas/areas.js.map +1 -0
- package/dist/schemas/automations.d.ts +19 -3
- package/dist/schemas/automations.d.ts.map +1 -1
- package/dist/schemas/automations.js +2 -0
- package/dist/schemas/automations.js.map +1 -1
- package/dist/schemas/capabilities.d.ts +11 -46
- package/dist/schemas/capabilities.d.ts.map +1 -1
- package/dist/schemas/capabilities.js +15 -14
- package/dist/schemas/capabilities.js.map +1 -1
- package/dist/schemas/personas.d.ts.map +1 -1
- package/dist/schemas/personas.js +5 -1
- package/dist/schemas/personas.js.map +1 -1
- package/dist/schemas/providers/provider-oauth.d.ts +2 -0
- package/dist/schemas/providers/provider-oauth.d.ts.map +1 -1
- package/dist/schemas/providers/provider-oauth.js +4 -0
- package/dist/schemas/providers/provider-oauth.js.map +1 -1
- package/dist/schemas/providers/usage.d.ts +4 -0
- package/dist/schemas/providers/usage.d.ts.map +1 -1
- package/dist/schemas/providers/usage.js +4 -0
- package/dist/schemas/providers/usage.js.map +1 -1
- package/dist/schemas/settings.d.ts +99 -3
- package/dist/schemas/settings.d.ts.map +1 -1
- package/dist/schemas/settings.js +51 -14
- package/dist/schemas/settings.js.map +1 -1
- package/dist/schemas/shared.d.ts +4 -0
- package/dist/schemas/shared.d.ts.map +1 -1
- package/dist/schemas/shared.js +3 -3
- package/dist/schemas/shared.js.map +1 -1
- package/dist/schemas/turn-break.d.ts +32 -0
- package/dist/schemas/turn-break.d.ts.map +1 -0
- package/dist/schemas/turn-break.js +16 -0
- package/dist/schemas/turn-break.js.map +1 -0
- package/dist/state/definition.d.ts +40 -56
- 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 +2 -0
- package/dist/state/history-state.js.map +1 -1
- package/dist/state/workspace-state.d.ts +5 -0
- package/dist/state/workspace-state.d.ts.map +1 -1
- package/dist/state/workspace-state.js +1 -0
- package/dist/state/workspace-state.js.map +1 -1
- package/dist/text/transcript-fold.d.ts.map +1 -1
- package/dist/text/transcript-fold.js +3 -7
- package/dist/text/transcript-fold.js.map +1 -1
- package/dist/time/zone.d.ts +27 -0
- package/dist/time/zone.d.ts.map +1 -0
- package/dist/time/zone.js +35 -0
- package/dist/time/zone.js.map +1 -0
- package/package.json +16 -5
- package/src/contracts/agents.contract.ts +7 -30
- package/src/contracts/areas.contract.ts +42 -0
- package/src/contracts/endpoints.contract.ts +82 -0
- package/src/contracts/providers.contract.ts +5 -0
- package/src/contracts/settings.contract.ts +28 -0
- package/src/events/agent-events.ts +4 -1
- package/src/events/transcript.ts +9 -1
- package/src/index.ts +19 -1
- package/src/models/agent-catalog.ts +1 -1
- package/src/policy/fence-paths.test.ts +114 -0
- package/src/policy/fence-paths.ts +99 -0
- package/src/protocol/routes.test.ts +12 -0
- package/src/protocol/routes.ts +8 -1
- package/src/schemas/agents.ts +14 -35
- package/src/schemas/areas.test.ts +40 -0
- package/src/schemas/areas.ts +49 -0
- package/src/schemas/automations.ts +8 -0
- package/src/schemas/capabilities.ts +37 -19
- package/src/schemas/personas.ts +15 -4
- package/src/schemas/providers/provider-oauth.ts +9 -0
- package/src/schemas/providers/usage.ts +9 -0
- package/src/schemas/settings.ts +107 -26
- package/src/schemas/shared.ts +9 -6
- package/src/schemas/turn-break.ts +49 -0
- package/src/state/history-state.ts +7 -0
- package/src/state/workspace-state.test.ts +7 -3
- package/src/state/workspace-state.ts +9 -2
- package/src/text/transcript-fold.test.ts +3 -5
- package/src/text/transcript-fold.ts +3 -7
- package/src/time/zone.test.ts +90 -0
- package/src/time/zone.ts +118 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { PUBLIC_DIR, STATE_DIR } from "@intentic/constants";
|
|
2
|
+
|
|
3
|
+
// The path arithmetic a workspace fence is made of: whether a path is inside a folder, whether one set of folders
|
|
4
|
+
// covers another, and what a fenced tree may still list. Pure and shared, because the daemon refuses on these answers
|
|
5
|
+
// and the browser draws on them, and two implementations would disagree at exactly the boundary that matters.
|
|
6
|
+
|
|
7
|
+
// Workspace-relative, forward-slash, no climb: segments are folded so `a/./b` and `a/b` are one path, and a path that
|
|
8
|
+
// climbs above the root folds to `undefined` rather than to something inside it.
|
|
9
|
+
// The root itself is "" — a real answer, not a missing one.
|
|
10
|
+
export const foldPath = (raw: string): string | undefined => {
|
|
11
|
+
if (raw.startsWith("/") || /^[A-Za-z]:/.test(raw)) {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
const segments: string[] = [];
|
|
15
|
+
for (const segment of raw.split(/[\\/]/)) {
|
|
16
|
+
if (segment === "" || segment === ".") {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (segment !== "..") {
|
|
20
|
+
segments.push(segment);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (segments.pop() === undefined) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return segments.join("/");
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Whether `path` is `folder` or lives under it, by segment rather than by string prefix: `app2` is not inside `app`.
|
|
31
|
+
// An unfoldable path is inside nothing.
|
|
32
|
+
export const pathInsideFolder = (folder: string, path: string): boolean => {
|
|
33
|
+
const outer = foldPath(folder);
|
|
34
|
+
const inner = foldPath(path);
|
|
35
|
+
if (outer === undefined || inner === undefined) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return outer === "" || inner === outer || inner.startsWith(`${outer}/`);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// "Change the sandbox" as paths: the daemon's own configuration, and the outbox the internet reads. One list, read by
|
|
42
|
+
// the persona hook that judges a turn's writes and by the area schema that decides what a grant may name.
|
|
43
|
+
export const SANDBOX_PATHS: readonly string[] = [STATE_DIR, PUBLIC_DIR];
|
|
44
|
+
|
|
45
|
+
// Whether a path is one of those folders or sits inside one, by segment: `publications` is not `public`.
|
|
46
|
+
// No area may name such a folder, which is what keeps a fence from handing anyone the config that decides what agents
|
|
47
|
+
// may do — the enumerated control-plane table (state/workspace-state.ts) locks named entries, not the whole tree.
|
|
48
|
+
export const isSandboxPath = (path: string): boolean => SANDBOX_PATHS.some((folder) => pathInsideFolder(folder, path));
|
|
49
|
+
|
|
50
|
+
// A fence: the folders a caller may touch, or undefined for the whole workspace. Undefined and `[]` are deliberately
|
|
51
|
+
// different answers — no fence at all, versus a fence that admits nothing.
|
|
52
|
+
export type Fence = readonly string[] | undefined;
|
|
53
|
+
|
|
54
|
+
/** Whether the fence admits this path at all: reading it, writing it, searching in it. */
|
|
55
|
+
export const fenceAllows = (fence: Fence, path: string): boolean => fence === undefined || fence.some((folder) => pathInsideFolder(folder, path));
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether a fenced tree may still LIST this path: everything the fence allows, plus the ancestors leading down to it.
|
|
59
|
+
* Without the ancestors a fence on `finance/reports` would hide `finance` itself and leave the folder unreachable.
|
|
60
|
+
*/
|
|
61
|
+
export const fenceReaches = (fence: Fence, path: string): boolean => {
|
|
62
|
+
if (fenceAllows(fence, path)) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
const inner = foldPath(path);
|
|
66
|
+
return inner !== undefined && (fence ?? []).some((folder) => pathInsideFolder(inner, folder));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether `outer` covers `inner`: every folder the inner fence admits is one the outer fence admits too. The question
|
|
71
|
+
* behind every narrowing-only rule — handing work across, inheriting a fence — since a fence that covers another can
|
|
72
|
+
* safely stand in for it.
|
|
73
|
+
*/
|
|
74
|
+
export const fenceCovers = (outer: Fence, inner: Fence): boolean => {
|
|
75
|
+
if (outer === undefined) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
if (inner === undefined) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return inner.every((folder) => fenceAllows(outer, folder));
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The tighter of two fences: what a turn may touch when a persona's folders meet the member's own. Undefined on both
|
|
86
|
+
* sides is the whole workspace; either side alone narrows to itself.
|
|
87
|
+
*/
|
|
88
|
+
export const fenceIntersection = (left: Fence, right: Fence): Fence => {
|
|
89
|
+
if (left === undefined) {
|
|
90
|
+
return right;
|
|
91
|
+
}
|
|
92
|
+
if (right === undefined) {
|
|
93
|
+
return left;
|
|
94
|
+
}
|
|
95
|
+
// A folder survives only where one side contains it; the containing side's folder is already the narrower of the
|
|
96
|
+
// pair, so taking each side's contained folders and deduping gives the intersection with no overlap left over.
|
|
97
|
+
const kept = [...left.filter((folder) => fenceAllows(right, folder)), ...right.filter((folder) => fenceAllows(left, folder))];
|
|
98
|
+
return [...new Set(kept.map((folder) => foldPath(folder) ?? folder))];
|
|
99
|
+
};
|
|
@@ -47,6 +47,18 @@ describe(`routeNameForRequest`, () => {
|
|
|
47
47
|
expect(routeNameForRequest(routes, `DELETE`, `/system/terminals/web-1`)).toBe(`system.killTerminal`);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
// Sorted by name, `agents.get` precedes `agents.search`, so first-match would read `/agents/search` as an id.
|
|
51
|
+
it(`prefers a literal segment over a parameter, whichever the sorted list holds first`, () => {
|
|
52
|
+
const siblings = contractRoutes({
|
|
53
|
+
agents: {
|
|
54
|
+
get: oc.route({ method: "GET", path: "/agents/{id}" }),
|
|
55
|
+
search: oc.route({ method: "GET", path: "/agents/search" }),
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
expect(routeNameForRequest(siblings, `GET`, `/agents/search`)).toBe(`agents.search`);
|
|
59
|
+
expect(routeNameForRequest(siblings, `GET`, `/agents/abc`)).toBe(`agents.get`);
|
|
60
|
+
});
|
|
61
|
+
|
|
50
62
|
it(`strips the query string before matching`, () => {
|
|
51
63
|
expect(routeNameForRequest(routes, `GET`, `/vpn?refresh=1`)).toBe(`vpn.list`);
|
|
52
64
|
});
|
package/src/protocol/routes.ts
CHANGED
|
@@ -156,10 +156,17 @@ const pathMatches = (template: string, path: string): boolean => {
|
|
|
156
156
|
|
|
157
157
|
// The contract route a request belongs to, or undefined for a hand-written daemon route (/health, /workspace/raw) never
|
|
158
158
|
// gated by the contract. Query string stripped first.
|
|
159
|
+
// How many segments a template leaves open; the tie-breaker below prefers the template that leaves fewest.
|
|
160
|
+
const paramCount = (template: string): number => template.split("/").filter((segment) => segment.startsWith("{") && segment.endsWith("}")).length;
|
|
161
|
+
|
|
159
162
|
export const routeNameForRequest = (routes: readonly ContractRoute[], method: string, pathWithQuery: string): string | undefined => {
|
|
160
163
|
const path = pathWithQuery.split("?")[0] ?? pathWithQuery;
|
|
161
164
|
const upper = method.toUpperCase();
|
|
162
|
-
|
|
165
|
+
// A literal segment outranks a parameter: `/agents/search` is the search route, not `get` with an id of "search",
|
|
166
|
+
// whichever of the two the sorted list happens to hold first.
|
|
167
|
+
return routes
|
|
168
|
+
.filter((route) => route.method.toUpperCase() === upper && pathMatches(route.path, path))
|
|
169
|
+
.toSorted((a, b) => paramCount(a.path) - paramCount(b.path))[0]?.name;
|
|
163
170
|
};
|
|
164
171
|
|
|
165
172
|
// The route a typed client call belongs to; oRPC addresses a procedure by contract position (`['git','stashApply']`),
|
package/src/schemas/agents.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { AgentHarnessSchema, AgentOriginSchema, AgentProviderSchema, ForkedFromSchema } from "./agent.js";
|
|
4
4
|
import { LoopStateSchema } from "./loops.js";
|
|
5
|
+
import { LimitPolicySchema, RetryPolicySchema, TurnBreakPolicySchema, TurnBreakSchema } from "./turn-break.js";
|
|
5
6
|
import { EMOJI_MAX_LENGTH, isSingleEmoji } from "../text/emoji.js";
|
|
6
7
|
// A fleet agent is any conversation with a registry entry, keyed by conversationId. Isolated ones own a git worktree
|
|
7
8
|
// (branch agent/<id>); workspace conversations have none, but both share one status/activity/cost lifecycle.
|
|
@@ -252,15 +253,11 @@ export const AgentSummarySchema = z.object({
|
|
|
252
253
|
.describe(
|
|
253
254
|
"This conversation's own answer to whether its work merges automatically. Absent means it follows the sandbox-wide setting, which is the common case.",
|
|
254
255
|
),
|
|
255
|
-
//
|
|
256
|
-
// can't arm every agent; absent inherits the sandbox
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
resumeAfterLimit: z.boolean().optional(),
|
|
261
|
-
// Per-conversation override for moving a held turn to another account with room the moment it's refused; absent
|
|
262
|
-
// inherits.
|
|
263
|
-
moveAfterLimit: z.boolean().optional(),
|
|
256
|
+
// This conversation's own answer to each ending's one question, written by the in-chat control (not the settings
|
|
257
|
+
// row) so one late-night click can't arm every agent; absent inherits the sandbox-wide policy.
|
|
258
|
+
limitPolicy: LimitPolicySchema.optional(),
|
|
259
|
+
outagePolicy: RetryPolicySchema.optional(),
|
|
260
|
+
stopPolicy: RetryPolicySchema.optional(),
|
|
264
261
|
// A collaborator's ask to land (collaborators can't merge themselves); cleared by whichever merge or discard
|
|
265
262
|
// answers it.
|
|
266
263
|
landRequested: z
|
|
@@ -619,33 +616,15 @@ export const AgentAutoLandSchema = z.object({
|
|
|
619
616
|
"Whether its work merges automatically when a turn finishes. Null clears the override and goes back to following the sandbox-wide setting, so a conversation does not sit holding a frozen copy of a default it has quietly stopped following.",
|
|
620
617
|
),
|
|
621
618
|
});
|
|
622
|
-
// Same `null`-clears-the-override shape as autoLand, for this conversation's own
|
|
623
|
-
|
|
619
|
+
// Same `null`-clears-the-override shape as autoLand, for this conversation's own answer to one ending's question.
|
|
620
|
+
// One route rather than one per ending: they are the same decision asked about different walls, and three near-identical
|
|
621
|
+
// verbs is how the surfaces drifted apart in the first place.
|
|
622
|
+
export const AgentBreakPolicySchema = z.object({
|
|
624
623
|
id: z.string().min(1).describe("Which conversation."),
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
.
|
|
628
|
-
|
|
629
|
-
});
|
|
630
|
-
// Same three-state override, for the limit blocker; written by the card's own offer when a limit strands a turn.
|
|
631
|
-
export const AgentResumeAfterLimitSchema = z.object({
|
|
632
|
-
id: z.string().min(1).describe("Which conversation."),
|
|
633
|
-
resumeAfterLimit: z
|
|
634
|
-
.boolean()
|
|
635
|
-
.nullable()
|
|
636
|
-
.describe(
|
|
637
|
-
"Whether the turn a spent allowance refused is sent again by itself once the window reopens. Null clears the override back to the sandbox-wide setting.",
|
|
638
|
-
),
|
|
639
|
-
});
|
|
640
|
-
// Same three-state override, for moving a held turn to another account with room.
|
|
641
|
-
export const AgentMoveAfterLimitSchema = z.object({
|
|
642
|
-
id: z.string().min(1).describe("Which conversation."),
|
|
643
|
-
moveAfterLimit: z
|
|
644
|
-
.boolean()
|
|
645
|
-
.nullable()
|
|
646
|
-
.describe(
|
|
647
|
-
"Whether the turn a spent allowance refused is moved to another connected account of the same provider that has room, as soon as the refusal lands. Null clears the override back to the sandbox-wide setting.",
|
|
648
|
-
),
|
|
624
|
+
ending: TurnBreakSchema.describe("Which wall this answers for: a spent usage limit, a provider outage, or a turn that stopped short."),
|
|
625
|
+
policy: TurnBreakPolicySchema.nullable().describe(
|
|
626
|
+
"What happens next for that ending. `wait` holds the turn for a press; `retry` re-runs it on a bounded ladder (outage, stop); `resend` sends it again at the published reset and `move` also tries another account with room (limit only). An answer the ending does not allow is refused. Null clears the override and goes back to following the sandbox-wide policy, so a conversation does not sit holding a frozen copy of a default it has quietly stopped following.",
|
|
627
|
+
),
|
|
649
628
|
});
|
|
650
629
|
export const AgentFileDiffQuerySchema = z.object({
|
|
651
630
|
id: z.string().min(1).describe("Which conversation."),
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { PUBLIC_DIR, STATE_DIR } from "@intentic/constants";
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import { AreaFolderSchema } from "./areas.js";
|
|
4
|
+
|
|
5
|
+
// What an area may name. These refusals are load-bearing rather than cosmetic: an area's folders become the fence
|
|
6
|
+
// every file route refuses on, so they are the whole of what keeps a grant below maintainer out of the config that
|
|
7
|
+
// decides what agents may do.
|
|
8
|
+
describe("AreaFolderSchema", () => {
|
|
9
|
+
test("an ordinary folder, at any depth, is an area", () => {
|
|
10
|
+
expect(AreaFolderSchema.safeParse("support").success).toBe(true);
|
|
11
|
+
expect(AreaFolderSchema.safeParse("finance/reports").success).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("the workspace root is not an area: naming no area at all is what granting everything means", () => {
|
|
15
|
+
expect(AreaFolderSchema.safeParse(".").success).toBe(false);
|
|
16
|
+
expect(AreaFolderSchema.safeParse("/").success).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("a folder that climbs out of the workspace is refused however it is spelled", () => {
|
|
20
|
+
expect(AreaFolderSchema.safeParse("../etc").success).toBe(false);
|
|
21
|
+
expect(AreaFolderSchema.safeParse("support/../..").success).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("the sandbox's own configuration and its outbox can never be named", () => {
|
|
25
|
+
for (const folder of [
|
|
26
|
+
STATE_DIR,
|
|
27
|
+
`${STATE_DIR}/config`,
|
|
28
|
+
`${STATE_DIR}/config/hooks`,
|
|
29
|
+
PUBLIC_DIR,
|
|
30
|
+
`${PUBLIC_DIR}/site`,
|
|
31
|
+
]) {
|
|
32
|
+
expect(AreaFolderSchema.safeParse(folder).success, folder).toBe(false);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a folder that merely starts with one of those names is ordinary", () => {
|
|
37
|
+
expect(AreaFolderSchema.safeParse("publications").success).toBe(true);
|
|
38
|
+
expect(AreaFolderSchema.safeParse("support/public-notes").success).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { foldPath, isSandboxPath } from "../policy/fence-paths.js";
|
|
3
|
+
import { entryId } from "./internal.js";
|
|
4
|
+
|
|
5
|
+
// A named part of the workspace: the unit a person's reach is granted in. Named rather than listed per member because
|
|
6
|
+
// a folder list on every row makes each new folder an edit per member, and two rows meant to see the same thing drift
|
|
7
|
+
// apart the first time one is updated and the other isn't.
|
|
8
|
+
// An area is not a credential and holds none; what it holds is a decision about who sees which folders, which is why
|
|
9
|
+
// the file is tracked and a change to it shows up in review.
|
|
10
|
+
|
|
11
|
+
// Workspace-relative, forward-slash, no climb. Checked rather than rewritten: a transform here would make the route's
|
|
12
|
+
// wire shape inexpressible, and two builds compare surfaces by that shape. The route folds what it writes instead, so
|
|
13
|
+
// two spellings of one folder still cannot make two entries.
|
|
14
|
+
export const AreaFolderSchema = z
|
|
15
|
+
.string()
|
|
16
|
+
.min(1)
|
|
17
|
+
.max(200)
|
|
18
|
+
.refine((raw) => (foldPath(raw) ?? "") !== "", {
|
|
19
|
+
message: "a folder is workspace-relative and inside the workspace; the workspace root is what naming no area already means",
|
|
20
|
+
})
|
|
21
|
+
// A fence is what a writer's every file route refuses on, so an area naming the control plane would be the one way
|
|
22
|
+
// a grant below maintainer could reach the config that decides what agents may do, or publish to the internet.
|
|
23
|
+
.refine((raw) => !isSandboxPath(raw), {
|
|
24
|
+
message: "an area cannot name the sandbox's own configuration or its public outbox",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const AreaSchema = z.object({
|
|
28
|
+
id: entryId.describe("The area's id, the name a member row points at."),
|
|
29
|
+
label: z.string().max(60).optional().describe("What to call it on screen. Absent falls back to the id, which somebody chose anyway."),
|
|
30
|
+
brief: z
|
|
31
|
+
.string()
|
|
32
|
+
.max(200)
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("What this part of the workspace is, in one line, so whoever grants it can tell what they are handing over."),
|
|
35
|
+
folders: z
|
|
36
|
+
.array(AreaFolderSchema)
|
|
37
|
+
.min(1)
|
|
38
|
+
.max(50)
|
|
39
|
+
.describe(
|
|
40
|
+
"The folders it admits, workspace-relative. At least one: an area naming nothing would be a grant with no reader, and the way to grant everything is to name no area at all.",
|
|
41
|
+
),
|
|
42
|
+
});
|
|
43
|
+
export type Area = z.infer<typeof AreaSchema>;
|
|
44
|
+
|
|
45
|
+
export const AreasListSchema = z.object({
|
|
46
|
+
areas: z.array(AreaSchema).describe("Every named part of the workspace this sandbox grants access in."),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const AreaIdParamSchema = z.object({ id: entryId.describe("Which area.") });
|
|
@@ -4,6 +4,7 @@ import { AgentOriginSchema, ModelPinSchema } from "./agent.js";
|
|
|
4
4
|
import { AgentSummarySchema } from "./agents.js";
|
|
5
5
|
import { entryId } from "./internal.js";
|
|
6
6
|
import { IssuesConfigSchema } from "./issues.js";
|
|
7
|
+
import { ZoneSchema } from "../time/zone.js";
|
|
7
8
|
// An automation wakes the agent: the daemon fires each enabled one on its trigger, runs the optional guard command
|
|
8
9
|
// (non-zero exit skips the wake), then runs one turn with the prompt. The manifest is user config; run history is
|
|
9
10
|
// daemon-recorded.
|
|
@@ -60,6 +61,13 @@ export const TriggerSchema = z.discriminatedUnion("kind", [
|
|
|
60
61
|
z.object({
|
|
61
62
|
kind: z.literal("schedule").describe("On a clock."),
|
|
62
63
|
cron: z.string().min(1).describe("When, in cron notation."),
|
|
64
|
+
// A cron is a WALL-CLOCK RULE: "43 20 * * *" is not a moment, it is 20:43 on some clock, and which clock is
|
|
65
|
+
// not written in it. The composer sends the reader's own zone here; absent, the sandbox's `timezone` setting
|
|
66
|
+
// answers, and absent that, UTC. Stored per automation rather than only globally so one chore can keep a
|
|
67
|
+
// colleague's hours, or a market's, without moving everything else.
|
|
68
|
+
tz: ZoneSchema.optional().describe(
|
|
69
|
+
"Which clock the times in the cron mean, as a zone name like Europe/Warsaw. Leave it out to use the sandbox's own setting, which is what you want unless this one chore belongs to a different place.",
|
|
70
|
+
),
|
|
63
71
|
// Fires only once at least this many other sessions have started since this automation's last wake; a due run
|
|
64
72
|
// short of that is recorded as skipped.
|
|
65
73
|
afterSessions: z
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { ExitConfigSchema } from "./exit.js";
|
|
5
5
|
import { entryId } from "./internal.js";
|
|
6
|
-
import { ServiceKindSchema } from "./inventory.js";
|
|
7
6
|
import { NetdiskConfigSchema } from "./netdisk.js";
|
|
8
7
|
import { VpnConfigSchema } from "./vpn.js";
|
|
9
8
|
// One-way: the browser extension bundles webext.js alone, so nothing there may reach back into this file.
|
|
@@ -15,8 +14,6 @@ export const CapabilityKindSchema = z.enum([
|
|
|
15
14
|
"devops",
|
|
16
15
|
"monorepo",
|
|
17
16
|
"mcp",
|
|
18
|
-
"service",
|
|
19
|
-
"integration",
|
|
20
17
|
"cli",
|
|
21
18
|
"plugin",
|
|
22
19
|
"extension",
|
|
@@ -42,18 +39,6 @@ export const McpConfigSchema = z.object({
|
|
|
42
39
|
url: z.url().describe("Where the tool server answers."),
|
|
43
40
|
token: z.string().optional().describe("The credential it needs, if any. Stored, never echoed back."),
|
|
44
41
|
});
|
|
45
|
-
export const ServiceConfigSchema = z.object({
|
|
46
|
-
service: ServiceKindSchema.describe("Which service to provision."),
|
|
47
|
-
domain: z.string().min(1).describe("The address it should answer on."),
|
|
48
|
-
on: z.string().min(1).describe("Which machine to put it on."),
|
|
49
|
-
expose: z.string().min(1).describe("How it should be reachable."),
|
|
50
|
-
});
|
|
51
|
-
// External-app credential injected into deployed apps (i.have.stripe → STRIPE_API_KEY), not agent-facing like `cli`.
|
|
52
|
-
// Closed, unlike `cli`: it becomes an `i.have.<provider>` deploy.config.ts entry, so the vocabulary belongs to the
|
|
53
|
-
// deploy engine, not an extension.
|
|
54
|
-
export const IntegrationConfigSchema = z.object({
|
|
55
|
-
provider: z.literal("stripe").describe("Which outside service's credential to make available to deployed apps."),
|
|
56
|
-
});
|
|
57
42
|
// Gives the agent an authenticated CLI tool: credential plus any non-secret URL, injected into the agent's env each
|
|
58
43
|
// turn, taught via an .agents/skills/<id> cheatsheet. Provider fields are data in an extension's
|
|
59
44
|
// `contributes.capabilities`, validated at add-time, not by this schema.
|
|
@@ -234,6 +219,43 @@ export const LOCAL_MODEL_WINDOW_DEFAULT: LocalModelWindow = "65536";
|
|
|
234
219
|
// ceiling no shipped GGUF was trained for it.
|
|
235
220
|
export const LOCAL_MODEL_WINDOW_MIN = 2048;
|
|
236
221
|
export const LOCAL_MODEL_WINDOW_MAX = 1_048_576;
|
|
222
|
+
// q8_0 KV cost per token, the rate every window price derives from: 64 KiB, so the rungs come out at exactly 1, 2, 4
|
|
223
|
+
// and 8 GiB. One rate rather than a per-model one, because the true cost varies ~2x across the list (50–60 KB/token
|
|
224
|
+
// measured) and a per-row figure is arithmetic somebody redoes by hand on every model added. Rounded UP into that band
|
|
225
|
+
// rather than down: over-reserving costs a rung, under-reserving costs an allocation failure a card had promised
|
|
226
|
+
// against.
|
|
227
|
+
export const LOCAL_MODEL_KV_BYTES_PER_TOKEN = 65_536;
|
|
228
|
+
// What a rung honestly serves. `instant` is the one the connect view prefetches — it downloads in under a minute and
|
|
229
|
+
// cannot drive a full agent turn, so no surface may sell it as one; `work` is what that view recommends for real use.
|
|
230
|
+
export type LocalModelTier = "instant" | "work";
|
|
231
|
+
export interface LocalModelChoice {
|
|
232
|
+
// Hugging Face owner/repo/file.gguf, exactly as LocalModelConfig.model carries it.
|
|
233
|
+
readonly id: string;
|
|
234
|
+
readonly label: string;
|
|
235
|
+
// The published file's own size, not a rounded guess: the card's label, the fit arithmetic and the download
|
|
236
|
+
// estimate all read this, so a model cannot be priced two ways.
|
|
237
|
+
readonly weightsBytes: number;
|
|
238
|
+
readonly tier: LocalModelTier;
|
|
239
|
+
}
|
|
240
|
+
// The curated list, smallest first. ponytail: these bytes are the Hugging Face tree API's answer for each file — the
|
|
241
|
+
// hand-written labels that predated them overstated gemma-4 12B by 2x and Qwen3.8 27B by a third, which is the class of
|
|
242
|
+
// drift a machine-readable size exists to end.
|
|
243
|
+
export const LOCAL_MODELS: readonly LocalModelChoice[] = [
|
|
244
|
+
{ id: "unsloth/Qwen3.5-2B-GGUF/Qwen3.5-2B-Q4_K_M.gguf", label: "Qwen3.5 2B", weightsBytes: 1_280_835_840, tier: "instant" },
|
|
245
|
+
{
|
|
246
|
+
id: "unsloth/Phi-4-mini-instruct-GGUF/Phi-4-mini-instruct-Q4_K_M.gguf",
|
|
247
|
+
label: "Phi-4-mini 3.8B",
|
|
248
|
+
weightsBytes: 2_491_874_272,
|
|
249
|
+
tier: "work",
|
|
250
|
+
},
|
|
251
|
+
{ id: "unsloth/Qwen3.5-9B-GGUF/Qwen3.5-9B-Q4_K_M.gguf", label: "Qwen3.5 9B", weightsBytes: 5_680_522_464, tier: "work" },
|
|
252
|
+
{ id: "unsloth/gemma-4-12b-it-GGUF/gemma-4-12b-it-Q4_K_M.gguf", label: "Gemma 4 12B", weightsBytes: 7_121_861_440, tier: "work" },
|
|
253
|
+
{ id: "unsloth/Qwen3.8-27B-GGUF/Qwen3.8-27B-UD-Q4_K_M.gguf", label: "Qwen3.8 27B", weightsBytes: 16_464_440_224, tier: "work" },
|
|
254
|
+
];
|
|
255
|
+
export const localModelChoice = (id: string): LocalModelChoice | undefined => LOCAL_MODELS.find((choice) => choice.id === id);
|
|
256
|
+
// Exactly one row carries the instant tier; the fit module and the prefetch route take it from here rather than
|
|
257
|
+
// repeating its id, so the prefetched bytes and the offered option cannot name different files.
|
|
258
|
+
export const LOCAL_MODEL_INSTANT: LocalModelChoice = LOCAL_MODELS.find((choice) => choice.tier === "instant")!;
|
|
237
259
|
export const LocalModelConfigSchema = z.object({
|
|
238
260
|
model: z.string().min(1),
|
|
239
261
|
gpu: z.enum(["on", "off"]).default("off"),
|
|
@@ -266,8 +288,6 @@ export const WalletConfigSchema = z.object({
|
|
|
266
288
|
});
|
|
267
289
|
export type WalletConfig = z.infer<typeof WalletConfigSchema>;
|
|
268
290
|
export type McpConfig = z.infer<typeof McpConfigSchema>;
|
|
269
|
-
export type ServiceConfig = z.infer<typeof ServiceConfigSchema>;
|
|
270
|
-
export type IntegrationConfig = z.infer<typeof IntegrationConfigSchema>;
|
|
271
291
|
export type CliConfig = z.infer<typeof CliConfigSchema>;
|
|
272
292
|
export type PluginConfig = z.infer<typeof PluginConfigSchema>;
|
|
273
293
|
export type ExtensionConfig = z.infer<typeof ExtensionConfigSchema>;
|
|
@@ -283,8 +303,6 @@ export const CapabilitySchema = z.discriminatedUnion("kind", [
|
|
|
283
303
|
// operator panel.
|
|
284
304
|
z.object({ id: entryId, kind: z.literal("monorepo"), config: z.object({}) }),
|
|
285
305
|
z.object({ id: entryId, kind: z.literal("mcp"), config: McpConfigSchema }),
|
|
286
|
-
z.object({ id: entryId, kind: z.literal("service"), config: ServiceConfigSchema }),
|
|
287
|
-
z.object({ id: entryId, kind: z.literal("integration"), config: IntegrationConfigSchema }),
|
|
288
306
|
z.object({ id: entryId, kind: z.literal("cli"), config: CliConfigSchema }),
|
|
289
307
|
z.object({ id: entryId, kind: z.literal("plugin"), config: PluginConfigSchema }),
|
|
290
308
|
z.object({ id: entryId, kind: z.literal("extension"), config: ExtensionConfigSchema }),
|
package/src/schemas/personas.ts
CHANGED
|
@@ -45,7 +45,11 @@ export const PersonaPowersSchema = z.object({
|
|
|
45
45
|
});
|
|
46
46
|
export type PersonaPowers = z.infer<typeof PersonaPowersSchema>;
|
|
47
47
|
// `folders` only refuses file-tool calls outside it; it stops a misread instruction, not a shell. The container is the
|
|
48
|
-
// real workspace-wide fence
|
|
48
|
+
// real workspace-wide fence, and a conversation started by a fenced person is narrower still — the turn works inside
|
|
49
|
+
// this persona's folders AND that person's areas, never the wider of the two.
|
|
50
|
+
// A plain folder list rather than a named area (schemas/areas.ts), because the two answer different questions: a
|
|
51
|
+
// persona's fence is written once for that persona, while an area is a grant several people hold and one edit has to
|
|
52
|
+
// move all of them.
|
|
49
53
|
// No placement field, by decision: every session already opens in its own private copy, never the shared tree.
|
|
50
54
|
export const PersonaWorkspaceSchema = z.object({
|
|
51
55
|
// Absent means the workspace root.
|
|
@@ -192,7 +196,9 @@ export const PersonaSchema = z.object({
|
|
|
192
196
|
.string()
|
|
193
197
|
.max(200)
|
|
194
198
|
.optional()
|
|
195
|
-
.describe(
|
|
199
|
+
.describe(
|
|
200
|
+
"What this persona is for, in one line. A new chat is routed onto a persona by this sentence, and the Personas page shows it under the name.",
|
|
201
|
+
),
|
|
196
202
|
powers: PersonaPowersSchema.optional().describe(
|
|
197
203
|
"What a conversation wearing it may do. Absent means the full toolbox, so a card written before this existed behaves exactly as it did.",
|
|
198
204
|
),
|
|
@@ -221,13 +227,18 @@ export const PersonaSchema = z.object({
|
|
|
221
227
|
export type Persona = z.infer<typeof PersonaSchema>;
|
|
222
228
|
// Ladder filtered to connected providers, deduplicated. Empty (absent, or every provider disconnected) means the card
|
|
223
229
|
// has no opinion — the caller decides, not "run nothing".
|
|
224
|
-
export const personaModels = (card: Pick<Persona, "models">, sources: readonly ModelSource[]): readonly ModelPin[] =>
|
|
230
|
+
export const personaModels = (card: Pick<Persona, "models">, sources: readonly ModelSource[]): readonly ModelPin[] =>
|
|
231
|
+
readyChain(sources, card.models ?? []);
|
|
225
232
|
// Asked once per chat, on the message it was sent with; answers with the one card the message belongs to, or none.
|
|
226
233
|
// `folder`/`paths` are the facts a card's `context`/`startIn` can be matched against that words alone can't supply.
|
|
227
234
|
export const PersonaRouteAskSchema = z.object({
|
|
228
235
|
prompt: z.string().min(1).max(20000).describe("The message a new chat is about to open with."),
|
|
229
236
|
folder: z.string().max(200).optional().describe("The workspace folder the chat was opened in, when it was opened in one."),
|
|
230
|
-
paths: z
|
|
237
|
+
paths: z
|
|
238
|
+
.array(z.string().min(1).max(500))
|
|
239
|
+
.max(50)
|
|
240
|
+
.default([])
|
|
241
|
+
.describe("Workspace paths the message names: uploads, @-mentions, the editor's own file."),
|
|
231
242
|
});
|
|
232
243
|
export type PersonaRouteAsk = z.infer<typeof PersonaRouteAskSchema>;
|
|
233
244
|
export const PersonaRouteSchema = z.object({
|
|
@@ -141,6 +141,15 @@ export const ModelSchema = z.object({
|
|
|
141
141
|
// The served window, not the training length — memory can clamp it lower. Turns are refused against this. Absent
|
|
142
142
|
// means unknown, never unlimited.
|
|
143
143
|
contextWindow: z.number().optional().describe("How many tokens this model will accept in one request, where the server publishes it."),
|
|
144
|
+
// Epoch seconds. A fact about the MODEL, not about any one account: a routed provider balances across credentials,
|
|
145
|
+
// so a model every one of them is benched on is unrunnable however much headroom their rings show. Absent is the
|
|
146
|
+
// ordinary case and means runnable, never "unknown".
|
|
147
|
+
availableAt: z
|
|
148
|
+
.number()
|
|
149
|
+
.optional()
|
|
150
|
+
.describe(
|
|
151
|
+
"When this model can be asked again, where every credential that serves it is currently refused. Absent means it can be asked now. A model here is still worth showing, unlike one the plan does not cover at all: the wait is the whole answer.",
|
|
152
|
+
),
|
|
144
153
|
});
|
|
145
154
|
export type Model = z.infer<typeof ModelSchema>;
|
|
146
155
|
export const ModelsSchema = z.object({
|
|
@@ -60,9 +60,18 @@ export const UsageTurnSchema = z.object({
|
|
|
60
60
|
openingListings: z.number().optional(),
|
|
61
61
|
// Tool calls before the turn first touched a file it later edited; absent (not zero) if it edited nothing.
|
|
62
62
|
callsBeforeTarget: z.number().optional(),
|
|
63
|
+
// Calls that ended in error, counted once each however many updates reported it; absent means unmeasured, not a
|
|
64
|
+
// clean turn.
|
|
65
|
+
failedCalls: z.number().optional(),
|
|
63
66
|
// Arm of the project-map experiment, stable per conversation; mapChars is the note's length when sent.
|
|
64
67
|
mapArm: z.boolean().optional(),
|
|
65
68
|
mapChars: z.number().optional(),
|
|
69
|
+
// Arm of the field-notes experiment, stable per conversation; notesChars is what the budget let through.
|
|
70
|
+
notesArm: z.boolean().optional(),
|
|
71
|
+
notesChars: z.number().optional(),
|
|
72
|
+
// Hash of the brief that was sent, recorded on control turns too. Load-bearing rather than decorative: the file is
|
|
73
|
+
// rewritten monthly, so a 30-day window holds two different treatments and pooling them would measure neither.
|
|
74
|
+
notesCohort: z.string().optional(),
|
|
66
75
|
// What became of pre-turn retrieval on this turn, and how long it took. Assignment and DELIVERY are different
|
|
67
76
|
// facts: the first version of this mechanism was assigned to every eligible turn and reached four in five of them,
|
|
68
77
|
// which is the difference between a null result and a mechanism that never ran. Absent means the flag was off, so
|