@intentic/sandbox-contract 1.337.0 → 1.339.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 +41 -0
- package/dist/chores/probes.d.ts.map +1 -1
- package/dist/chores/probes.js +4 -7
- package/dist/chores/probes.js.map +1 -1
- package/dist/contracts/agent.contract.d.ts +32 -23
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.d.ts +6020 -5994
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.js +13 -1
- package/dist/contracts/agents.contract.js.map +1 -1
- package/dist/contracts/approvals.contract.d.ts +8 -0
- package/dist/contracts/approvals.contract.d.ts.map +1 -1
- package/dist/contracts/automations.contract.d.ts +3 -3
- package/dist/contracts/capabilities.contract.d.ts +28 -0
- package/dist/contracts/capabilities.contract.d.ts.map +1 -1
- package/dist/contracts/capabilities.contract.js +11 -1
- package/dist/contracts/capabilities.contract.js.map +1 -1
- package/dist/contracts/device.contract.d.ts +225 -0
- package/dist/contracts/device.contract.d.ts.map +1 -1
- package/dist/contracts/device.contract.js +10 -0
- package/dist/contracts/device.contract.js.map +1 -1
- package/dist/contracts/extensions.contract.d.ts +16 -0
- package/dist/contracts/extensions.contract.d.ts.map +1 -1
- package/dist/contracts/needs.contract.d.ts +22 -7
- package/dist/contracts/needs.contract.d.ts.map +1 -1
- package/dist/contracts/org.contract.d.ts +35 -0
- package/dist/contracts/org.contract.d.ts.map +1 -0
- package/dist/contracts/org.contract.js +14 -0
- package/dist/contracts/org.contract.js.map +1 -0
- package/dist/contracts/phone.contract.d.ts +1 -0
- package/dist/contracts/phone.contract.d.ts.map +1 -1
- package/dist/contracts/privacy.contract.d.ts +115 -0
- package/dist/contracts/privacy.contract.d.ts.map +1 -1
- package/dist/contracts/privacy.contract.js +11 -2
- package/dist/contracts/privacy.contract.js.map +1 -1
- package/dist/contracts/runner.contract.d.ts +11 -7
- package/dist/contracts/runner.contract.d.ts.map +1 -1
- package/dist/contracts/secrets.contract.d.ts +85 -0
- package/dist/contracts/secrets.contract.d.ts.map +1 -1
- package/dist/contracts/secrets.contract.js +48 -1
- package/dist/contracts/secrets.contract.js.map +1 -1
- package/dist/contracts/sessions.contract.d.ts +4 -1
- package/dist/contracts/sessions.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.d.ts +11 -3
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.d.ts +23 -2
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/events/agent-events.d.ts +17 -4
- package/dist/events/agent-events.d.ts.map +1 -1
- package/dist/events/requests.d.ts +4 -0
- package/dist/events/requests.d.ts.map +1 -1
- package/dist/events/requests.js +1 -1
- package/dist/events/requests.js.map +1 -1
- package/dist/events/system-events.d.ts +4 -0
- package/dist/events/system-events.d.ts.map +1 -1
- package/dist/events/system-events.js +2 -0
- package/dist/events/system-events.js.map +1 -1
- package/dist/events/transcript.d.ts +27 -6
- package/dist/events/transcript.d.ts.map +1 -1
- package/dist/index.d.ts +7562 -7426
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -257
- package/dist/index.js.map +1 -1
- package/dist/models/agent-lanes.d.ts +30 -0
- package/dist/models/agent-lanes.d.ts.map +1 -0
- package/dist/models/agent-lanes.js +67 -0
- package/dist/models/agent-lanes.js.map +1 -0
- package/dist/models/agent-standing.d.ts +55 -0
- package/dist/models/agent-standing.d.ts.map +1 -0
- package/dist/models/agent-standing.js +82 -0
- package/dist/models/agent-standing.js.map +1 -0
- package/dist/models/agent-starter.d.ts +10 -0
- package/dist/models/agent-starter.d.ts.map +1 -0
- package/dist/models/agent-starter.js +16 -0
- package/dist/models/agent-starter.js.map +1 -0
- package/dist/netd/generated/wire.d.ts +1 -1
- package/dist/netd/generated/wire.d.ts.map +1 -1
- package/dist/policy/identity-assertion.d.ts.map +1 -1
- package/dist/policy/identity-assertion.js +2 -8
- package/dist/policy/identity-assertion.js.map +1 -1
- package/dist/policy/keyring-seal.d.ts +48 -0
- package/dist/policy/keyring-seal.d.ts.map +1 -0
- package/dist/policy/keyring-seal.js +94 -0
- package/dist/policy/keyring-seal.js.map +1 -0
- package/dist/policy/message-limits.d.ts +2 -0
- package/dist/policy/message-limits.d.ts.map +1 -1
- package/dist/policy/message-limits.js +2 -0
- package/dist/policy/message-limits.js.map +1 -1
- package/dist/policy/org-locks.d.ts +58 -0
- package/dist/policy/org-locks.d.ts.map +1 -0
- package/dist/policy/org-locks.js +113 -0
- package/dist/policy/org-locks.js.map +1 -0
- package/dist/policy/persona-capability.d.ts +14 -0
- package/dist/policy/persona-capability.d.ts.map +1 -0
- package/dist/policy/persona-capability.js +23 -0
- package/dist/policy/persona-capability.js.map +1 -0
- package/dist/policy/reserved-servers.d.ts.map +1 -1
- package/dist/policy/reserved-servers.js +1 -1
- package/dist/policy/reserved-servers.js.map +1 -1
- package/dist/protocol/doors/phone-links.d.ts +5 -0
- package/dist/protocol/doors/phone-links.d.ts.map +1 -1
- package/dist/protocol/doors/phone-links.js +3 -1
- package/dist/protocol/doors/phone-links.js.map +1 -1
- package/dist/protocol/doors/webext-links.d.ts +3 -0
- package/dist/protocol/doors/webext-links.d.ts.map +1 -1
- package/dist/protocol/doors/webext-links.js +3 -0
- package/dist/protocol/doors/webext-links.js.map +1 -1
- package/dist/protocol/raw/raw-json-routes.d.ts +270 -0
- package/dist/protocol/raw/raw-json-routes.d.ts.map +1 -1
- package/dist/protocol/raw/raw-json-routes.js +14 -1
- package/dist/protocol/raw/raw-json-routes.js.map +1 -1
- package/dist/protocol/raw/raw-routes.d.ts +70 -0
- package/dist/protocol/raw/raw-routes.d.ts.map +1 -1
- package/dist/protocol/raw/raw-routes.js +18 -0
- package/dist/protocol/raw/raw-routes.js.map +1 -1
- package/dist/protocol/route-meta.d.ts.map +1 -1
- package/dist/protocol/route-meta.js.map +1 -1
- package/dist/protocol/routes.d.ts +4 -0
- package/dist/protocol/routes.d.ts.map +1 -1
- package/dist/protocol/routes.js +12 -0
- package/dist/protocol/routes.js.map +1 -1
- package/dist/schemas/agents-list.d.ts +5 -0
- package/dist/schemas/agents-list.d.ts.map +1 -1
- package/dist/schemas/agents-list.js +8 -0
- package/dist/schemas/agents-list.js.map +1 -1
- package/dist/schemas/agents.d.ts +1 -0
- package/dist/schemas/agents.d.ts.map +1 -1
- package/dist/schemas/agents.js +1 -0
- package/dist/schemas/agents.js.map +1 -1
- package/dist/schemas/approvals.d.ts +20 -0
- package/dist/schemas/approvals.d.ts.map +1 -1
- package/dist/schemas/approvals.js +10 -0
- package/dist/schemas/approvals.js.map +1 -1
- package/dist/schemas/capabilities.d.ts +26 -0
- package/dist/schemas/capabilities.d.ts.map +1 -1
- package/dist/schemas/capabilities.js +17 -0
- package/dist/schemas/capabilities.js.map +1 -1
- package/dist/schemas/data-protection.d.ts +5 -0
- package/dist/schemas/data-protection.d.ts.map +1 -1
- package/dist/schemas/data-protection.js +29 -0
- package/dist/schemas/data-protection.js.map +1 -1
- package/dist/schemas/devices.d.ts +20 -0
- package/dist/schemas/devices.d.ts.map +1 -1
- package/dist/schemas/devices.js +5 -1
- package/dist/schemas/devices.js.map +1 -1
- package/dist/schemas/extension-updates.d.ts +32 -0
- package/dist/schemas/extension-updates.d.ts.map +1 -1
- package/dist/schemas/git/git.d.ts +19 -0
- package/dist/schemas/git/git.d.ts.map +1 -1
- package/dist/schemas/git/git.js +12 -1
- package/dist/schemas/git/git.js.map +1 -1
- package/dist/schemas/hosts.d.ts +2 -0
- package/dist/schemas/hosts.d.ts.map +1 -1
- package/dist/schemas/hosts.js +11 -1
- package/dist/schemas/hosts.js.map +1 -1
- package/dist/schemas/intentic-os.d.ts +218 -0
- package/dist/schemas/intentic-os.d.ts.map +1 -1
- package/dist/schemas/intentic-os.js +166 -0
- package/dist/schemas/intentic-os.js.map +1 -1
- package/dist/schemas/keyring.d.ts +421 -0
- package/dist/schemas/keyring.d.ts.map +1 -0
- package/dist/schemas/keyring.js +176 -0
- package/dist/schemas/keyring.js.map +1 -0
- package/dist/schemas/links.d.ts +208 -0
- package/dist/schemas/links.d.ts.map +1 -0
- package/dist/schemas/links.js +116 -0
- package/dist/schemas/links.js.map +1 -0
- package/dist/schemas/maintenance.d.ts.map +1 -1
- package/dist/schemas/maintenance.js.map +1 -1
- package/dist/schemas/members.d.ts.map +1 -1
- package/dist/schemas/members.js +2 -0
- package/dist/schemas/members.js.map +1 -1
- package/dist/schemas/needs.d.ts +25 -8
- package/dist/schemas/needs.d.ts.map +1 -1
- package/dist/schemas/needs.js +15 -3
- package/dist/schemas/needs.js.map +1 -1
- package/dist/schemas/organisation.d.ts +106 -0
- package/dist/schemas/organisation.d.ts.map +1 -0
- package/dist/schemas/organisation.js +52 -0
- package/dist/schemas/organisation.js.map +1 -0
- package/dist/schemas/phone.d.ts +3 -0
- package/dist/schemas/phone.d.ts.map +1 -1
- package/dist/schemas/phone.js +1 -0
- package/dist/schemas/phone.js.map +1 -1
- package/dist/schemas/privacy.d.ts +232 -0
- package/dist/schemas/privacy.d.ts.map +1 -1
- package/dist/schemas/privacy.js +154 -4
- package/dist/schemas/privacy.js.map +1 -1
- package/dist/schemas/secrets.d.ts +151 -0
- package/dist/schemas/secrets.d.ts.map +1 -1
- package/dist/schemas/secrets.js +71 -8
- package/dist/schemas/secrets.js.map +1 -1
- package/dist/schemas/settings.d.ts +8 -0
- package/dist/schemas/settings.d.ts.map +1 -1
- package/dist/schemas/settings.js +4 -0
- package/dist/schemas/settings.js.map +1 -1
- package/dist/schemas/speech.d.ts +12 -0
- package/dist/schemas/speech.d.ts.map +1 -1
- package/dist/schemas/speech.js +23 -0
- package/dist/schemas/speech.js.map +1 -1
- package/dist/schemas/system.d.ts +2 -0
- package/dist/schemas/system.d.ts.map +1 -1
- package/dist/schemas/system.js +1 -1
- package/dist/schemas/system.js.map +1 -1
- package/dist/schemas/terminal.d.ts +15 -0
- package/dist/schemas/terminal.d.ts.map +1 -1
- package/dist/schemas/terminal.js +11 -2
- package/dist/schemas/terminal.js.map +1 -1
- package/dist/schemas/voice.d.ts +3 -0
- package/dist/schemas/voice.d.ts.map +1 -1
- package/dist/schemas/voice.js +7 -0
- package/dist/schemas/voice.js.map +1 -1
- package/dist/schemas/webext.d.ts +17 -0
- package/dist/schemas/webext.d.ts.map +1 -1
- package/dist/schemas/webext.js +7 -1
- package/dist/schemas/webext.js.map +1 -1
- package/dist/state/contract-lock.d.ts +3 -1
- package/dist/state/contract-lock.d.ts.map +1 -1
- package/dist/state/contract-lock.js +34 -9
- package/dist/state/contract-lock.js.map +1 -1
- package/dist/state/contract-modules.d.ts +4 -0
- package/dist/state/contract-modules.d.ts.map +1 -0
- package/dist/state/contract-modules.js +28 -0
- package/dist/state/contract-modules.js.map +1 -0
- package/dist/state/daemon-floor.d.ts +4 -0
- package/dist/state/daemon-floor.d.ts.map +1 -0
- package/dist/state/daemon-floor.js +8 -0
- package/dist/state/daemon-floor.js.map +1 -0
- package/dist/state/definition.d.ts +34 -0
- 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 +8 -0
- package/dist/state/history-state.js.map +1 -1
- package/dist/text/documents.d.ts +3 -1
- package/dist/text/documents.d.ts.map +1 -1
- package/dist/text/documents.js +30 -0
- package/dist/text/documents.js.map +1 -1
- package/dist/text/pages.d.ts.map +1 -1
- package/dist/text/pages.js +3 -7
- package/dist/text/pages.js.map +1 -1
- package/package.json +27 -5
- package/src/chores/probes.ts +4 -6
- package/src/contracts/agents.contract.ts +18 -1
- package/src/contracts/capabilities.contract.ts +14 -0
- package/src/contracts/device.contract.ts +30 -0
- package/src/contracts/org.contract.ts +19 -0
- package/src/contracts/privacy.contract.ts +13 -1
- package/src/contracts/secrets.contract.ts +64 -0
- package/src/events/requests.ts +3 -1
- package/src/events/system-events.ts +9 -0
- package/src/index.ts +41 -279
- package/src/models/agent-lanes.ts +152 -0
- package/src/models/agent-standing.ts +279 -0
- package/src/models/agent-starter.ts +26 -0
- package/src/netd/generated/netd-wire.json +2 -1
- package/src/netd/generated/wire.ts +1 -1
- package/src/policy/identity-assertion.ts +2 -8
- package/src/policy/keyring-seal.test.ts +93 -0
- package/src/policy/keyring-seal.ts +148 -0
- package/src/policy/message-limits.ts +3 -0
- package/src/policy/org-locks.ts +173 -0
- package/src/policy/persona-capability.ts +57 -0
- package/src/policy/reserved-servers.ts +3 -2
- package/src/policy/tests/org-locks.test.ts +92 -0
- package/src/policy/tests/reserved-servers.test.ts +3 -2
- package/src/protocol/doors/listener-protocol.ts +1 -1
- package/src/protocol/doors/phone-links.ts +11 -2
- package/src/protocol/doors/phone-protocol.test.ts +8 -1
- package/src/protocol/doors/webext-links.ts +13 -0
- package/src/protocol/ingress-contract.fixture.json +2 -0
- package/src/protocol/pairing-redeem.ts +1 -1
- package/src/protocol/peer-dial.test.ts +6 -6
- package/src/protocol/peer-mcp-server.test.ts +2 -1
- package/src/protocol/raw/raw-json-routes.ts +31 -1
- package/src/protocol/raw/raw-routes.ts +35 -1
- package/src/protocol/route-meta.ts +2 -1
- package/src/protocol/routes.test.ts +33 -1
- package/src/protocol/routes.ts +18 -0
- package/src/protocol/sse.ts +1 -1
- package/src/schemas/agent.ts +1 -1
- package/src/schemas/agents-list.ts +13 -0
- package/src/schemas/agents.ts +1 -0
- package/src/schemas/approvals.ts +14 -0
- package/src/schemas/capabilities.ts +25 -0
- package/src/schemas/data-protection.ts +47 -0
- package/src/schemas/devices.ts +15 -1
- package/src/schemas/git/git.ts +22 -2
- package/src/schemas/hosts.ts +14 -1
- package/src/schemas/intentic-os.test.ts +148 -0
- package/src/schemas/intentic-os.ts +259 -0
- package/src/schemas/keyring.ts +277 -0
- package/src/schemas/links.ts +180 -0
- package/src/schemas/maintenance.ts +1 -0
- package/src/schemas/members.ts +4 -0
- package/src/schemas/needs.ts +21 -5
- package/src/schemas/organisation.ts +110 -0
- package/src/schemas/phone.ts +3 -0
- package/src/schemas/privacy.test.ts +63 -0
- package/src/schemas/privacy.ts +213 -5
- package/src/schemas/secrets.ts +96 -8
- package/src/schemas/settings.ts +8 -0
- package/src/schemas/speech.ts +38 -1
- package/src/schemas/system.ts +5 -2
- package/src/schemas/terminal.ts +13 -2
- package/src/schemas/voice.ts +11 -0
- package/src/schemas/webext.ts +16 -1
- package/src/state/contract-lock.test.ts +12 -12
- package/src/state/contract-lock.ts +43 -12
- package/src/state/contract-modules.ts +41 -0
- package/src/state/daemon-floor.test.ts +81 -0
- package/src/state/daemon-floor.ts +32 -0
- package/src/state/history-state.ts +17 -0
- package/src/state/workspace-state.ts +1 -1
- package/src/text/documents.test.ts +30 -1
- package/src/text/documents.ts +43 -1
- package/src/text/pages.ts +5 -7
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AgentAttention,
|
|
3
|
+
type AgentJob,
|
|
4
|
+
type AgentStatus,
|
|
5
|
+
type AgentWatch,
|
|
6
|
+
awaitsWake,
|
|
7
|
+
type LandConflictReason,
|
|
8
|
+
type LandFailure,
|
|
9
|
+
} from "../schemas/agents.js";
|
|
10
|
+
import type { QueuePause } from "../schemas/agent.js";
|
|
11
|
+
|
|
12
|
+
// (2026-10-16) Moved here from the editor (_editor/web/src/models/agent-status/agentStatus-standing.ts, which re-exports
|
|
13
|
+
// it), so the daemon counts what needs the person by the very rules the board draws with (models/agent-lanes.ts): the
|
|
14
|
+
// desktop app's status bar asks the daemon (`agents.attention`), and the board and the bar must never disagree.
|
|
15
|
+
|
|
16
|
+
// Every projection of a fleet agent's state (lane, attention label, drill-in verb, glyphs). Nothing else may
|
|
17
|
+
// derive these from `status` alone: a parked turn is `idle` with an attention flag raised. Pure functions over
|
|
18
|
+
// plain data, no store, no Vue.
|
|
19
|
+
|
|
20
|
+
// Four standings a conversation gets when the daemon has not registered it. `draft`: not sent yet. `starting`:
|
|
21
|
+
// sent but not yet filed as an agent. `failed`: send refused, never became an agent. `resumed`: a past conversation
|
|
22
|
+
// reopened from History with no surviving registry entry. All four have no registry entry, so nothing to archive,
|
|
23
|
+
// review, land or drop (`unregistered` below). None may be widened into `AgentStatus`: that enum is only the
|
|
24
|
+
// daemon's account of agents it has.
|
|
25
|
+
export type ClientAgentStatus = "draft" | "starting" | "failed" | "resumed";
|
|
26
|
+
|
|
27
|
+
// Enough of an agent to place one; every predicate below takes only this, so a FleetAgent, a roster AgentSummary,
|
|
28
|
+
// or a test literal can all answer the same question. Each optional field also takes `undefined`, as the contract's own
|
|
29
|
+
// AgentSummary spells it, so the daemon (strict about the difference) hands its roster over as it is.
|
|
30
|
+
export interface AgentStanding {
|
|
31
|
+
readonly status: AgentStatus | ClientAgentStatus;
|
|
32
|
+
readonly attention: AgentAttention;
|
|
33
|
+
// Outside conditions this conversation is parked on (AgentSummary.watches); `laneOf` reads it alongside `status`
|
|
34
|
+
// and `attention`. Absent for nearly every conversation.
|
|
35
|
+
readonly watches?: readonly AgentWatch[] | undefined;
|
|
36
|
+
// Whether it runs again by itself (AgentSummary.awaitingWake): the daemon's one reading, which `watching` asks.
|
|
37
|
+
readonly awaitingWake?: boolean | undefined;
|
|
38
|
+
// The commands it left running and how the recent ones ended (AgentSummary.jobs); `heldByPrompt` reads which of its
|
|
39
|
+
// watches a job at a prompt holds. Absent for nearly every conversation.
|
|
40
|
+
readonly jobs?: readonly AgentJob[] | undefined;
|
|
41
|
+
// Why a refused land is still refusing (AgentSummary.conflictCauses), which decides whose press can clear it.
|
|
42
|
+
// Absent for every card but one refusing to merge.
|
|
43
|
+
readonly conflictCauses?: readonly LandConflictReason[] | undefined;
|
|
44
|
+
// Which kind of failure ended the last turn. `status: "error"` alone conflates a broken harness with a spent
|
|
45
|
+
// allowance; both belong in Attention, but this says which so the card can tell them apart. `laneOf` reads it too,
|
|
46
|
+
// so the lane, badge and chip stay in agreement.
|
|
47
|
+
readonly failureCode?: string | undefined;
|
|
48
|
+
// The words that turn died on (AgentSummary.failure), cleared with the code when it runs again.
|
|
49
|
+
readonly failure?: string | undefined;
|
|
50
|
+
/** When the spent allowance reopens, in epoch SECONDS (the wire's unit). Absent when nobody published one. */
|
|
51
|
+
readonly limitResetsAt?: number | undefined;
|
|
52
|
+
/** Whether the refused turn is held whole, so a press re-runs it rather than sending a new message after it. */
|
|
53
|
+
readonly limitHeld?: boolean | undefined;
|
|
54
|
+
/** Whether a fire is already booked for it (at the reset, or a move now), so no press is needed. */
|
|
55
|
+
readonly limitScheduled?: boolean | undefined;
|
|
56
|
+
/** The account the owner's policy is moving the held turn to, while that move is booked (a scheduled card's sentence). */
|
|
57
|
+
readonly limitMoving?: string | undefined;
|
|
58
|
+
// A land that broke (AgentSummary.landFailure), standing until one goes through whatever turns run after it. Absent
|
|
59
|
+
// from a sandbox older than it, where only a turn's own `failure` can say so.
|
|
60
|
+
readonly landFailure?: LandFailure | undefined;
|
|
61
|
+
// What waits for its next turn (AgentSummary.queue), read here only for its hold: messages booked to go by
|
|
62
|
+
// themselves at a time or once another agent's work lands (`scheduled`), which make the conversation one that runs
|
|
63
|
+
// again by itself. Never the words.
|
|
64
|
+
readonly queue?: QueueHold | undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The part of a conversation's queue the board reads: why it holds, and what a scheduled hold waits for. A queue-level
|
|
68
|
+
// `scheduled` with its `until` or `after` is the soonest booked message's; a Stop's or a refusal's hold on the messages
|
|
69
|
+
// with no booking takes that place, so each message's own booking (`items`, from sandboxes that book each message on its
|
|
70
|
+
// own) is read then.
|
|
71
|
+
export interface QueueHold {
|
|
72
|
+
readonly paused?: QueuePause | undefined;
|
|
73
|
+
readonly until?: number | undefined;
|
|
74
|
+
readonly after?: string | undefined;
|
|
75
|
+
readonly items?: readonly { readonly until?: number | undefined; readonly after?: string | undefined }[] | undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The soonest booking a queue holds, as a scheduled hold: the queue's own while it reads `scheduled`, else the earliest
|
|
79
|
+
// message's time, else the first message waiting on a land; undefined when nothing is booked. One thing, never both.
|
|
80
|
+
export const soonestHold = (queue: QueueHold | undefined): QueueHold | undefined => {
|
|
81
|
+
if (queue?.paused === "scheduled") {
|
|
82
|
+
if (queue.after !== undefined) {
|
|
83
|
+
return { paused: "scheduled", after: queue.after };
|
|
84
|
+
}
|
|
85
|
+
return queue.until === undefined ? { paused: "scheduled" } : { paused: "scheduled", until: queue.until };
|
|
86
|
+
}
|
|
87
|
+
const items = queue?.items ?? [];
|
|
88
|
+
const instants = items.flatMap((item) => (item.until === undefined ? [] : [item.until]));
|
|
89
|
+
if (instants.length > 0) {
|
|
90
|
+
return { paused: "scheduled", until: Math.min(...instants) };
|
|
91
|
+
}
|
|
92
|
+
const after = items.find((item) => item.after !== undefined)?.after;
|
|
93
|
+
return after === undefined ? undefined : { paused: "scheduled", after };
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Messages booked to go by themselves (sendLater.ts, or a spent allowance's reopen): the conversation runs again with
|
|
97
|
+
// nobody pressing anything, so it is not finished, and nothing is owed by the reader for it to.
|
|
98
|
+
export const scheduledSend = (agent: Pick<AgentStanding, "queue">): boolean => soonestHold(agent.queue) !== undefined;
|
|
99
|
+
|
|
100
|
+
// How many messages are booked to go by themselves: each by its own booking, or all of them when an older sandbox booked
|
|
101
|
+
// the queue whole. What an archive by hand cancels (the sandbox's archive.ts), which the card's Archive says first.
|
|
102
|
+
export const scheduledCount = (agent: Pick<AgentStanding, "queue">): number => {
|
|
103
|
+
const items = agent.queue?.items ?? [];
|
|
104
|
+
return agent.queue?.paused === "scheduled" ? items.length : items.filter((item) => item.until !== undefined || item.after !== undefined).length;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// A spent allowance, not a failure to fix: nothing broken, and it comes back. Read off `failureCode` rather than
|
|
108
|
+
// the provider's own sentence, which varies per provider; the code is the daemon's own classification
|
|
109
|
+
// (error-frames.ts).
|
|
110
|
+
export const limited = (agent: AgentStanding): boolean => agent.status === "error" && agent.failureCode === "rate_limit";
|
|
111
|
+
|
|
112
|
+
// A message held at the door because the sandbox is short of memory (turn-admission): nothing ran and nothing failed,
|
|
113
|
+
// it waits for a person's "send anyway". The daemon files it as an `error`, so this is read off the code, as `limited`
|
|
114
|
+
// is, and the chip and glyph say held rather than error; the lane stays Attention, since the press is the reader's.
|
|
115
|
+
export const memoryHeld = (agent: Pick<AgentStanding, "status" | "failureCode">): boolean =>
|
|
116
|
+
agent.status === "error" && agent.failureCode === "sandbox-memory-low";
|
|
117
|
+
|
|
118
|
+
// Whether the window is still shut, feeding only the card's countdown, never the lane: a stranded card stays in
|
|
119
|
+
// Attention shut or open. No published instant means never closed, not a guessed one.
|
|
120
|
+
export const limitClosed = (agent: AgentStanding, now: number = Date.now()): boolean =>
|
|
121
|
+
limited(agent) && agent.limitResetsAt !== undefined && agent.limitResetsAt * 1_000 > now;
|
|
122
|
+
|
|
123
|
+
// A booked fire needs no press: the held turn goes again at the reset by itself. Only where this conversation's answer
|
|
124
|
+
// for the limit says so (`resend` or `move`); that answer starts at `wait`. Nothing is owed then, so it leaves Attention
|
|
125
|
+
// for Active, as an armed watch does: a card counted in the badge and the tab title for the hours until a reset the
|
|
126
|
+
// reader already answered was a call nobody could answer. The stall still shows, on the card's own chip and clock.
|
|
127
|
+
export const limitScheduled = (agent: AgentStanding): boolean => limited(agent) && agent.limitScheduled === true;
|
|
128
|
+
|
|
129
|
+
// A booked move: the owner's policy is already carrying the held turn to another account, on the resume pass's next
|
|
130
|
+
// beat, seconds away, so the card names where it is going rather than when it goes.
|
|
131
|
+
export const limitMoveBooked = (agent: AgentStanding): boolean => limitScheduled(agent) && agent.limitMoving !== undefined;
|
|
132
|
+
|
|
133
|
+
// A refused land nothing the agent does can clear: every blocker left is a file the user has uncommitted edits on,
|
|
134
|
+
// which only a commit or stash releases (git cannot merge through unstaged work, and the agent's checkout cannot
|
|
135
|
+
// even see it). The distinction the board used to lack: it offered "have the agent resolve it" for every conflict,
|
|
136
|
+
// including the ones where that press was refused the moment it was made.
|
|
137
|
+
// False when the daemon named no causes, which is a build that predates them or a refusal it could not attribute;
|
|
138
|
+
// the board then falls back to offering the agent, as it always did.
|
|
139
|
+
export const conflictIsYours = (agent: AgentStanding): boolean =>
|
|
140
|
+
agent.conflictCauses !== undefined && agent.conflictCauses.length > 0 && agent.conflictCauses.every((cause) => cause === "workspace");
|
|
141
|
+
|
|
142
|
+
// This conversation runs again by itself, with nobody pressing anything: its last turn ended (status is `idle`/`landed`,
|
|
143
|
+
// never `ready`, since the daemon holds the land for the wake), but the conversation is not over. The daemon's own
|
|
144
|
+
// reading (a watch, or a wake waiting in its queue); a daemon older than it is read by its watches.
|
|
145
|
+
export const watching = (agent: AgentStanding): boolean => awaitsWake(agent);
|
|
146
|
+
|
|
147
|
+
// The running job behind one of its armed watches that sits at a prompt (AgentJob.inputWait: blocked reading its
|
|
148
|
+
// terminal, nothing moving), the first such where there are several. Its watch fires only on an exit that will not
|
|
149
|
+
// come, so the card names it and offers to end it.
|
|
150
|
+
export const promptHeldJob = (agent: AgentStanding): AgentJob | undefined => {
|
|
151
|
+
const armed = new Set((agent.watches ?? []).map((watch) => watch.id));
|
|
152
|
+
return agent.jobs?.find((job) => job.endedAt === undefined && job.inputWait !== undefined && job.watch !== undefined && armed.has(job.watch));
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Parked only on jobs at a prompt: every watch it holds waits on an exit nobody in the sandbox will bring about, so the
|
|
156
|
+
// conversation is not resuming by itself, it is stuck until the reader ends the command (or types into its terminal).
|
|
157
|
+
// Not while a turn runs, whose own readout says what it is doing. On 2026-10-04 such a card sat in Active for hours
|
|
158
|
+
// under a six-hour countdown, with its Land press hidden behind the wake it was waiting for.
|
|
159
|
+
export const heldByPrompt = (agent: AgentStanding): boolean => {
|
|
160
|
+
const watches = agent.watches ?? [];
|
|
161
|
+
if (watches.length === 0 || turnInFlight(agent)) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
const prompts = new Set(
|
|
165
|
+
(agent.jobs ?? [])
|
|
166
|
+
.filter((job) => job.endedAt === undefined && job.inputWait !== undefined && job.watch !== undefined)
|
|
167
|
+
.map((job) => job.watch),
|
|
168
|
+
);
|
|
169
|
+
return watches.every((watch) => prompts.has(watch.id));
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// No registry entry behind this card: archiving, reviewing, landing, discarding and dropping all address an agent
|
|
173
|
+
// by id through the daemon, and none of these ids name anything there.
|
|
174
|
+
// Takes the status alone, unlike the lane predicates: callers like the tab's `open` and the detail page's
|
|
175
|
+
// `registered` hold a status without an attention block to pair it with.
|
|
176
|
+
export const unregistered = (status: AgentStatus | ClientAgentStatus): boolean =>
|
|
177
|
+
status === "draft" || status === "starting" || status === "failed" || status === "resumed";
|
|
178
|
+
|
|
179
|
+
// A turn is in flight: running, unwinding after a Stop, or repairing itself after its daemon died. Every hands-off
|
|
180
|
+
// guard (its worktree is a live turn's working state) and the live readouts (elapsed, activity line) key off this.
|
|
181
|
+
// `starting` counts even though nothing is registered yet, elapsed should tick from the send. `dismissing` and
|
|
182
|
+
// `landing` count for the guards only; both settle in Finished, so their lane is decided separately (see laneOf).
|
|
183
|
+
// A land runs no turn, but the daemon holds the worktree exactly as a turn would, and every guard wants the same answer.
|
|
184
|
+
// `awaiting` is deliberately excluded: it's live but parked, handled by `awaitingUser` instead.
|
|
185
|
+
export const turnInFlight = (agent: AgentStanding): boolean =>
|
|
186
|
+
agent.status === "running" ||
|
|
187
|
+
agent.status === "starting" ||
|
|
188
|
+
agent.status === "stopping" ||
|
|
189
|
+
agent.status === "dismissing" ||
|
|
190
|
+
agent.status === "resuming" ||
|
|
191
|
+
agent.status === "landing";
|
|
192
|
+
|
|
193
|
+
// The two standings a person's ending unwinds as, from the press until the turn has let go: Stop, or waving away the
|
|
194
|
+
// question it was parked on. Both differ only in the lane they settle in (see laneOf).
|
|
195
|
+
export type EndingByHand = Extract<AgentStatus, "stopping" | "dismissing">;
|
|
196
|
+
|
|
197
|
+
// How a person ended this turn, while it unwinds; undefined for a turn nobody ended. The one reading both the board's
|
|
198
|
+
// card and the chat's live line are drawn from, so the two say the same word about the same press.
|
|
199
|
+
export const endingOf = (agent: AgentStanding): EndingByHand | undefined =>
|
|
200
|
+
agent.status === "stopping" || agent.status === "dismissing" ? agent.status : undefined;
|
|
201
|
+
|
|
202
|
+
// A person already ended this turn and it's unwinding. Narrower than `turnInFlight`, which also covers turns nobody
|
|
203
|
+
// ended.
|
|
204
|
+
export const endingByHand = (agent: AgentStanding): boolean => endingOf(agent) !== undefined;
|
|
205
|
+
|
|
206
|
+
// A turn actually producing something, as a card's readouts mean it: `landing` is in flight for the hands-off guards
|
|
207
|
+
// but spends no model, and its `startedAt` belongs to the turn before it, so an elapsed clock would be someone else's.
|
|
208
|
+
// A turn a person already ended produces nothing more either: its readout is the ending itself (the card's chip, the
|
|
209
|
+
// chat's line), not the step it was on under a clock still counting, which reads as a Stop that did not take.
|
|
210
|
+
export const turnWorking = (agent: AgentStanding): boolean => turnInFlight(agent) && agent.status !== "landing" && !endingByHand(agent);
|
|
211
|
+
|
|
212
|
+
// The browser's copy of the daemon's `writing` guard (agents-registry.ts); exists for Land, where typing vs.
|
|
213
|
+
// parked matters (everything else treats them alike). `stopping` and `resuming` are excluded because the provider
|
|
214
|
+
// isn't producing anything then; the two sides must stay in step or the UI offers a press the daemon refuses.
|
|
215
|
+
export const writingNow = (agent: AgentStanding): boolean => agent.status === "running" || agent.status === "starting";
|
|
216
|
+
|
|
217
|
+
// "Blocked on you": the agent cannot proceed (or has failed) until the user acts. Not the same as unread.
|
|
218
|
+
// `stopped`/`interrupted` leave a half-written worktree only a new message can carry forward; `stopping` is
|
|
219
|
+
// counted the moment the press lands rather than once the unwind finishes, since it settles in the same lane
|
|
220
|
+
// either way; `failed` is the same kind of dead end one step earlier, before a worktree existed. A spent allowance
|
|
221
|
+
// counts until something is booked to carry it on (limitScheduled): a resend at the reset, or a move now.
|
|
222
|
+
const BLOCKING_ENDINGS: ReadonlySet<AgentStatus | ClientAgentStatus> = new Set(["error", "interrupted", "stopping", "stopped", "failed"]);
|
|
223
|
+
|
|
224
|
+
// A land that broke and still stands: its work is stuck on the branch, whatever the turn after it did, so the card is
|
|
225
|
+
// the reader's to look at. Not while a turn or a land is under way, which ends in another land of its own.
|
|
226
|
+
export const landBroken = (agent: AgentStanding): boolean => agent.landFailure !== undefined && !turnInFlight(agent);
|
|
227
|
+
|
|
228
|
+
export const blocked = (agent: AgentStanding): boolean =>
|
|
229
|
+
limitScheduled(agent)
|
|
230
|
+
? false
|
|
231
|
+
: agent.attention.plan ||
|
|
232
|
+
agent.attention.question ||
|
|
233
|
+
agent.attention.permission ||
|
|
234
|
+
agent.attention.capability ||
|
|
235
|
+
agent.attention.conflict ||
|
|
236
|
+
// A need outlives its turn (docs/architecture/needs.md): a conversation still running carries on beside it
|
|
237
|
+
// and stays in Active, one that stopped is waiting on nothing but the answer.
|
|
238
|
+
(agent.attention.need === true && agent.status !== "running") ||
|
|
239
|
+
landBroken(agent) ||
|
|
240
|
+
heldByPrompt(agent) ||
|
|
241
|
+
BLOCKING_ENDINGS.has(agent.status);
|
|
242
|
+
|
|
243
|
+
// The half of `blocked` that is literally waiting on an answer (plan, question, permission, capability), narrower
|
|
244
|
+
// than `blocked`: dead ends (a failed turn, an unlandable conflict) want looking at but owe the user nothing, so
|
|
245
|
+
// ending the agent loses no answer.
|
|
246
|
+
export const awaitingUser = (agent: AgentStanding): boolean =>
|
|
247
|
+
agent.attention.plan || agent.attention.question || agent.attention.permission || agent.attention.capability || agent.status === "awaiting";
|
|
248
|
+
|
|
249
|
+
// An ask only the owner may answer: a plan to approve, a permission, a setup, a credential release, a hand-off to a
|
|
250
|
+
// browser or terminal (a bare `awaiting`), or a refused land only their own commit clears. The parent a child agent
|
|
251
|
+
// works for can answer its question (subagents/children.ts) and hears how its turns end, but none of these, so these
|
|
252
|
+
// are what reach the reader from a child whatever its parent is doing (callsOwner).
|
|
253
|
+
export const onlyOwnerCanAnswer = (agent: AgentStanding): boolean =>
|
|
254
|
+
agent.attention.plan ||
|
|
255
|
+
agent.attention.permission ||
|
|
256
|
+
agent.attention.capability ||
|
|
257
|
+
agent.attention.credential ||
|
|
258
|
+
agent.attention.need === true ||
|
|
259
|
+
(agent.status === "awaiting" && !agent.attention.question) ||
|
|
260
|
+
((agent.attention.conflict || agent.status === "conflict") && conflictIsYours(agent));
|
|
261
|
+
|
|
262
|
+
// Whether a child agent's stop is news its parent has already had. Every ending of a turn the parent started reaches
|
|
263
|
+
// the parent (subagents/child-report.ts: a parked `wait` takes it, or it wakes the parent like any wake), so a parent
|
|
264
|
+
// that moved after the child stopped (ran a turn, landed) heard it, and its own ending is the account the reader gets:
|
|
265
|
+
// an orchestrator that worked around three spent allowances and landed is done, not stuck. Only a turn's ending
|
|
266
|
+
// counts. A question is still parked and waits for an answer whoever has moved since, and a land refused or broken
|
|
267
|
+
// leaves work stuck on the child's branch that no report settles. A turn a person ran in the child's own chat is never
|
|
268
|
+
// reported to the parent, and it ends after the parent's last move, so it still reads as unheard here.
|
|
269
|
+
// `updatedAt` is the clock on both sides: a rename keeps it (agents-registry), and reading does not move it.
|
|
270
|
+
export const heardByParent = (child: AgentStanding & { readonly updatedAt: number }, parentAt: number): boolean =>
|
|
271
|
+
BLOCKING_ENDINGS.has(child.status) &&
|
|
272
|
+
// Still unwinding: its ending has not reached anyone yet.
|
|
273
|
+
child.status !== "stopping" &&
|
|
274
|
+
// Booked to run again by itself: not over, whatever its parent made of the pause.
|
|
275
|
+
!limitScheduled(child) &&
|
|
276
|
+
!child.attention.question &&
|
|
277
|
+
!child.attention.conflict &&
|
|
278
|
+
!landBroken(child) &&
|
|
279
|
+
child.updatedAt < parentAt;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Who started a conversation when it was not a person, read off the summary's `startedBy` (the daemon's
|
|
2
|
+
// auth/principal.ts writes these prefixes). Moved here from the editor's ownership.ts (2026-10-16), which re-exports it,
|
|
3
|
+
// because which card a child agent rides under (models/agent-lanes.ts) is the daemon's count too.
|
|
4
|
+
|
|
5
|
+
// A starter that is not a person: a program holding a control token, or the conversation that spawned this one.
|
|
6
|
+
export type StarterLook = { readonly kind: "token"; readonly label: string } | { readonly kind: "child"; readonly parent: string };
|
|
7
|
+
|
|
8
|
+
const TOKEN_PREFIX = "token:";
|
|
9
|
+
const CHILD_PREFIX = "agent:";
|
|
10
|
+
|
|
11
|
+
export const starterLook = (startedBy: string | undefined): StarterLook | undefined => {
|
|
12
|
+
if (startedBy?.startsWith(TOKEN_PREFIX) === true) {
|
|
13
|
+
return { kind: "token", label: startedBy.slice(TOKEN_PREFIX.length) };
|
|
14
|
+
}
|
|
15
|
+
if (startedBy?.startsWith(CHILD_PREFIX) === true) {
|
|
16
|
+
return { kind: "child", parent: startedBy.slice(CHILD_PREFIX.length) };
|
|
17
|
+
}
|
|
18
|
+
return undefined;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// The conversation that spawned this one, when one did. A fact apart from whose it is: a child inherits its parent's
|
|
22
|
+
// owner, so on the reader's own work an ownership mark would never get to say it.
|
|
23
|
+
export const parentOf = (startedBy: string | undefined): string | undefined => {
|
|
24
|
+
const look = starterLook(startedBy);
|
|
25
|
+
return look?.kind === "child" ? look.parent : undefined;
|
|
26
|
+
};
|
|
@@ -41,7 +41,7 @@ export type NetdAnswer = { "answer": "sync", generations: Array<number | null>,
|
|
|
41
41
|
* Request headers only netd sets on what it forwards to Node; it strips them from everything arriving outside.
|
|
42
42
|
* Exported as a literal type, so a Node constant naming one that drifts from this spelling fails to compile.
|
|
43
43
|
*/
|
|
44
|
-
export type NetdHeader = "x-intentic-preview" | "x-intentic-preview-unreachable";
|
|
44
|
+
export type NetdHeader = "x-intentic-preview" | "x-intentic-preview-unreachable" | "x-intentic-listener";
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
47
|
* A question Node asks netd.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createPrivateKey, createPublicKey, randomBytes, sign as edSign, verify as edVerify } from "node:crypto";
|
|
2
|
+
import { parseJsonWith } from "@intentic/base/json";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { SANDBOX_ID } from "../ids/tunnel-ids.js";
|
|
4
5
|
|
|
@@ -92,14 +93,7 @@ const AssertionPayloadSchema = z.object({
|
|
|
92
93
|
jti: z.string().min(16),
|
|
93
94
|
});
|
|
94
95
|
|
|
95
|
-
const parsePayload = (payload: Buffer): z.infer<typeof AssertionPayloadSchema> | undefined =>
|
|
96
|
-
try {
|
|
97
|
-
return AssertionPayloadSchema.safeParse(JSON.parse(payload.toString("utf8"))).data;
|
|
98
|
-
} catch {
|
|
99
|
-
// allow(silent-catch): a payload that is not JSON is an invalid assertion, which undefined is how this says.
|
|
100
|
-
return undefined;
|
|
101
|
-
}
|
|
102
|
-
};
|
|
96
|
+
const parsePayload = (payload: Buffer): z.infer<typeof AssertionPayloadSchema> | undefined => parseJsonWith(payload.toString("utf8"), AssertionPayloadSchema);
|
|
103
97
|
|
|
104
98
|
// Verifies an assertion against the platform's public key at nowMs; undefined for any way of being invalid (wrong
|
|
105
99
|
// prefix, bad signature, malformed claim, expired, issued in the future, living longer than the daemon allows). The
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { KeyringGrant } from "../schemas/keyring.js";
|
|
2
|
+
import { grantNonce, keyFingerprint, newSealKeyPair, newSignKeyPair, seal, signGrant, unseal, verifyGrant } from "./keyring-seal.js";
|
|
3
|
+
|
|
4
|
+
const SANDBOX = "https://a1b2c3d4e5f6.intentic.dev";
|
|
5
|
+
|
|
6
|
+
const grantAt = (now: number, over: Partial<KeyringGrant> = {}): KeyringGrant => ({
|
|
7
|
+
v: 1,
|
|
8
|
+
sandbox: SANDBOX,
|
|
9
|
+
secret: "VPN_FORTI",
|
|
10
|
+
target: { kind: "window", programs: ["FortiClient.exe"], anyField: false },
|
|
11
|
+
purpose: "agent",
|
|
12
|
+
detail: "FortiClient sign-in",
|
|
13
|
+
nonce: grantNonce(),
|
|
14
|
+
issuedAt: now,
|
|
15
|
+
expiresAt: now + 60_000,
|
|
16
|
+
...over,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("sealed boxes", () => {
|
|
20
|
+
test("open for their own key and purpose", () => {
|
|
21
|
+
const pair = newSealKeyPair();
|
|
22
|
+
const purpose = { kind: "release", sandbox: SANDBOX, secret: "VPN_FORTI", nonce: grantNonce() } as const;
|
|
23
|
+
const box = seal(pair.publicKey, "hunter2-but-longer", purpose);
|
|
24
|
+
expect(unseal(pair, box, purpose)).toBe("hunter2-but-longer");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("refuse another key", () => {
|
|
28
|
+
const purpose = { kind: "inbox", sandbox: SANDBOX, secret: "X" } as const;
|
|
29
|
+
const box = seal(newSealKeyPair().publicKey, "value", purpose);
|
|
30
|
+
expect(() => unseal(newSealKeyPair(), box, purpose)).toThrow();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("refuse another purpose, so one release cannot stand in for another", () => {
|
|
34
|
+
const pair = newSealKeyPair();
|
|
35
|
+
const nonce = grantNonce();
|
|
36
|
+
const box = seal(pair.publicKey, "value", { kind: "release", sandbox: SANDBOX, secret: "A", nonce });
|
|
37
|
+
expect(() => unseal(pair, box, { kind: "release", sandbox: SANDBOX, secret: "B", nonce })).toThrow();
|
|
38
|
+
expect(() => unseal(pair, box, { kind: "type", sandbox: SANDBOX, secret: "A", nonce })).toThrow();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("are fresh every time, so equal values do not look equal", () => {
|
|
42
|
+
const pair = newSealKeyPair();
|
|
43
|
+
const purpose = { kind: "inbox", sandbox: SANDBOX, secret: "X" } as const;
|
|
44
|
+
expect(seal(pair.publicKey, "same", purpose).data).not.toBe(seal(pair.publicKey, "same", purpose).data);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("grants", () => {
|
|
49
|
+
test("verify under the signing key", () => {
|
|
50
|
+
const pair = newSignKeyPair();
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const verdict = verifyGrant(pair.publicKey, signGrant(pair, grantAt(now)), now + 1000);
|
|
53
|
+
expect(verdict.ok).toBe(true);
|
|
54
|
+
expect(verdict.ok && verdict.grant.secret).toBe("VPN_FORTI");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("refuse another sandbox's key", () => {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
const verdict = verifyGrant(newSignKeyPair().publicKey, signGrant(newSignKeyPair(), grantAt(now)), now);
|
|
60
|
+
expect(verdict).toMatchObject({ ok: false, code: "bad-grant" });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("refuse a payload changed after signing", () => {
|
|
64
|
+
const pair = newSignKeyPair();
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
const signed = signGrant(pair, grantAt(now));
|
|
67
|
+
// SAFETY: the payload is the grant signGrant just serialised, so it parses back to one.
|
|
68
|
+
const body = JSON.parse(Buffer.from(signed.payload, "base64url").toString("utf8")) as KeyringGrant;
|
|
69
|
+
const changed = Buffer.from(JSON.stringify({ ...body, secret: "BANK" }), "utf8").toString("base64url");
|
|
70
|
+
expect(verifyGrant(pair.publicKey, { ...signed, payload: changed }, now)).toMatchObject({ ok: false, code: "bad-grant" });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("expire", () => {
|
|
74
|
+
const pair = newSignKeyPair();
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
expect(verifyGrant(pair.publicKey, signGrant(pair, grantAt(now)), now + 61_000)).toMatchObject({ ok: false, code: "expired" });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("are never signed for longer than two minutes", () => {
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
expect(() => signGrant(newSignKeyPair(), grantAt(now, { expiresAt: now + 5 * 60_000 }))).toThrow();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("from far in the future are not this sandbox's", () => {
|
|
85
|
+
const pair = newSignKeyPair();
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
expect(verifyGrant(pair.publicKey, signGrant(pair, grantAt(now + 10 * 60_000)), now)).toMatchObject({ ok: false, code: "bad-grant" });
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("fingerprints read in groups of four", () => {
|
|
92
|
+
expect(keyFingerprint(newSealKeyPair().publicKey)).toMatch(/^[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/u);
|
|
93
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, sign, verify, type KeyObject } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
KEYRING_GRANT_TTL_MAX_MS,
|
|
5
|
+
KEYRING_KEY_RE,
|
|
6
|
+
KeyringGrantSchema,
|
|
7
|
+
type KeyringGrant,
|
|
8
|
+
type SealedBox,
|
|
9
|
+
SealedBoxSchema,
|
|
10
|
+
type SignedKeyringGrant,
|
|
11
|
+
} from "../schemas/keyring.js";
|
|
12
|
+
|
|
13
|
+
// The two primitives a device-held secret rides on (schemas/keyring.ts): a SEALED BOX, a value only the holder of one
|
|
14
|
+
// X25519 key can open, and a GRANT, the sandbox's Ed25519-signed word for one use of one value. Shared by the daemon,
|
|
15
|
+
// which signs grants and opens what is sealed to it, and the keyring, which verifies grants and seals; one file so the
|
|
16
|
+
// two cannot drift on an encoding.
|
|
17
|
+
|
|
18
|
+
const SEAL_INFO = Buffer.from("intentic-keyring/seal/v1", "utf8");
|
|
19
|
+
const GRANT_DOMAIN = Buffer.from("intentic-keyring/grant/v1\0", "utf8");
|
|
20
|
+
// A clock on either side may run a little ahead; a grant from much further in the future is not one this sandbox made.
|
|
21
|
+
const GRANT_SKEW_MS = 30_000;
|
|
22
|
+
|
|
23
|
+
// A key pair as it is stored: both halves raw, base64url, the `x` and `d` of their JWK.
|
|
24
|
+
export interface KeyringKeyPair {
|
|
25
|
+
readonly publicKey: string;
|
|
26
|
+
readonly privateKey: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Curve = "X25519" | "Ed25519";
|
|
30
|
+
|
|
31
|
+
// The halves of an OKP key's JWK, which Node always writes for a private key it just made.
|
|
32
|
+
const OkpJwkSchema = z.object({ x: z.string().regex(KEYRING_KEY_RE), d: z.string().regex(KEYRING_KEY_RE) });
|
|
33
|
+
|
|
34
|
+
const pairOf = (curve: Curve): KeyringKeyPair => {
|
|
35
|
+
const { privateKey } = curve === "X25519" ? generateKeyPairSync("x25519") : generateKeyPairSync("ed25519");
|
|
36
|
+
const jwk = OkpJwkSchema.parse(privateKey.export({ format: "jwk" }));
|
|
37
|
+
return { publicKey: jwk.x, privateKey: jwk.d };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const newSealKeyPair = (): KeyringKeyPair => pairOf("X25519");
|
|
41
|
+
export const newSignKeyPair = (): KeyringKeyPair => pairOf("Ed25519");
|
|
42
|
+
|
|
43
|
+
const publicKeyOf = (curve: Curve, x: string): KeyObject => createPublicKey({ key: { kty: "OKP", crv: curve, x }, format: "jwk" });
|
|
44
|
+
const privateKeyOf = (curve: Curve, pair: KeyringKeyPair): KeyObject =>
|
|
45
|
+
createPrivateKey({ key: { kty: "OKP", crv: curve, x: pair.publicKey, d: pair.privateKey }, format: "jwk" });
|
|
46
|
+
|
|
47
|
+
// What a box is sealed FOR, mixed into its authentication tag: a box opened under any other purpose fails, so a value
|
|
48
|
+
// released for one grant cannot be presented as another's, nor a typing payload as an inbox item.
|
|
49
|
+
export type SealPurpose =
|
|
50
|
+
| { readonly kind: "release"; readonly sandbox: string; readonly secret: string; readonly nonce: string }
|
|
51
|
+
| { readonly kind: "type"; readonly sandbox: string; readonly secret: string; readonly nonce: string }
|
|
52
|
+
| { readonly kind: "inbox"; readonly sandbox: string; readonly secret: string }
|
|
53
|
+
| { readonly kind: "copy"; readonly sandbox: string; readonly secret: string; readonly updatedAt: number }
|
|
54
|
+
| { readonly kind: "sync"; readonly account: string; readonly device: string; readonly revision: number };
|
|
55
|
+
|
|
56
|
+
export const sealAad = (purpose: SealPurpose): Buffer => {
|
|
57
|
+
switch (purpose.kind) {
|
|
58
|
+
case "release":
|
|
59
|
+
case "type":
|
|
60
|
+
return Buffer.from(`${purpose.kind}\0${purpose.sandbox}\0${purpose.secret}\0${purpose.nonce}`, "utf8");
|
|
61
|
+
case "inbox":
|
|
62
|
+
return Buffer.from(`inbox\0${purpose.sandbox}\0${purpose.secret}`, "utf8");
|
|
63
|
+
case "copy":
|
|
64
|
+
return Buffer.from(`copy\0${purpose.sandbox}\0${purpose.secret}\0${String(purpose.updatedAt)}`, "utf8");
|
|
65
|
+
case "sync":
|
|
66
|
+
return Buffer.from(`sync\0${purpose.account}\0${purpose.device}\0${String(purpose.revision)}`, "utf8");
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const boxKey = (shared: Buffer, epk: string, recipient: string): Buffer =>
|
|
71
|
+
Buffer.from(hkdfSync("sha256", shared, Buffer.concat([Buffer.from(epk, "base64url"), Buffer.from(recipient, "base64url")]), SEAL_INFO, 32));
|
|
72
|
+
|
|
73
|
+
// Seals `plaintext` to the holder of `recipient`'s private half, for `purpose` alone.
|
|
74
|
+
export const seal = (recipient: string, plaintext: string, purpose: SealPurpose): SealedBox => {
|
|
75
|
+
const ephemeral = newSealKeyPair();
|
|
76
|
+
const shared = diffieHellman({ privateKey: privateKeyOf("X25519", ephemeral), publicKey: publicKeyOf("X25519", recipient) });
|
|
77
|
+
const iv = randomBytes(12);
|
|
78
|
+
const cipher = createCipheriv("aes-256-gcm", boxKey(shared, ephemeral.publicKey, recipient), iv);
|
|
79
|
+
cipher.setAAD(sealAad(purpose));
|
|
80
|
+
const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
81
|
+
return { v: 1, epk: ephemeral.publicKey, iv: iv.toString("base64url"), tag: cipher.getAuthTag().toString("base64url"), data: data.toString("base64url") };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Opens a box sealed to `pair` for `purpose`; throws on any way of not being one (malformed, another key, another purpose).
|
|
85
|
+
export const unseal = (pair: KeyringKeyPair, box: SealedBox, purpose: SealPurpose): string => {
|
|
86
|
+
const parsed = SealedBoxSchema.parse(box);
|
|
87
|
+
const shared = diffieHellman({ privateKey: privateKeyOf("X25519", pair), publicKey: publicKeyOf("X25519", parsed.epk) });
|
|
88
|
+
const decipher = createDecipheriv("aes-256-gcm", boxKey(shared, parsed.epk, pair.publicKey), Buffer.from(parsed.iv, "base64url"));
|
|
89
|
+
decipher.setAAD(sealAad(purpose));
|
|
90
|
+
decipher.setAuthTag(Buffer.from(parsed.tag, "base64url"));
|
|
91
|
+
return Buffer.concat([decipher.update(Buffer.from(parsed.data, "base64url")), decipher.final()]).toString("utf8");
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// A fresh grant's nonce: 16 random bytes, base64url.
|
|
95
|
+
export const grantNonce = (): string => randomBytes(16).toString("base64url");
|
|
96
|
+
|
|
97
|
+
export const signGrant = (pair: KeyringKeyPair, grant: KeyringGrant): SignedKeyringGrant => {
|
|
98
|
+
const body = KeyringGrantSchema.parse(grant);
|
|
99
|
+
if (body.expiresAt - body.issuedAt > KEYRING_GRANT_TTL_MAX_MS || body.expiresAt <= body.issuedAt) {
|
|
100
|
+
throw new Error(`a keyring grant lives at most ${String(KEYRING_GRANT_TTL_MAX_MS / 1000)} s`);
|
|
101
|
+
}
|
|
102
|
+
const payload = Buffer.from(JSON.stringify(body), "utf8");
|
|
103
|
+
const sig = sign(null, Buffer.concat([GRANT_DOMAIN, payload]), privateKeyOf("Ed25519", pair));
|
|
104
|
+
return { payload: payload.toString("base64url"), sig: sig.toString("base64url") };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export type GrantVerdict =
|
|
108
|
+
| { readonly ok: true; readonly grant: KeyringGrant }
|
|
109
|
+
| { readonly ok: false; readonly code: "bad-grant" | "expired"; readonly reason: string };
|
|
110
|
+
|
|
111
|
+
// Verifies `signed` against the sandbox's pinned signing key at `nowMs`. The keyring checks the nonce, the share and
|
|
112
|
+
// the target against its own facts; this says only whether the sandbox said it, and said it recently.
|
|
113
|
+
export const verifyGrant = (signKey: string, signed: SignedKeyringGrant, nowMs: number): GrantVerdict => {
|
|
114
|
+
let payload: Buffer;
|
|
115
|
+
try {
|
|
116
|
+
payload = Buffer.from(signed.payload, "base64url");
|
|
117
|
+
if (!verify(null, Buffer.concat([GRANT_DOMAIN, payload]), publicKeyOf("Ed25519", signKey), Buffer.from(signed.sig, "base64url"))) {
|
|
118
|
+
return { ok: false, code: "bad-grant", reason: "the grant's signature is not the sandbox's" };
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
return { ok: false, code: "bad-grant", reason: "the grant could not be read" };
|
|
122
|
+
}
|
|
123
|
+
let parsed: unknown;
|
|
124
|
+
try {
|
|
125
|
+
parsed = JSON.parse(payload.toString("utf8"));
|
|
126
|
+
} catch {
|
|
127
|
+
return { ok: false, code: "bad-grant", reason: "the grant is not JSON" };
|
|
128
|
+
}
|
|
129
|
+
const grant = KeyringGrantSchema.safeParse(parsed);
|
|
130
|
+
if (!grant.success) {
|
|
131
|
+
return { ok: false, code: "bad-grant", reason: "the grant is not one this keyring knows how to read" };
|
|
132
|
+
}
|
|
133
|
+
const { issuedAt, expiresAt } = grant.data;
|
|
134
|
+
if (expiresAt - issuedAt > KEYRING_GRANT_TTL_MAX_MS || expiresAt <= issuedAt || issuedAt > nowMs + GRANT_SKEW_MS) {
|
|
135
|
+
return { ok: false, code: "bad-grant", reason: "the grant's times are not ones this sandbox would sign" };
|
|
136
|
+
}
|
|
137
|
+
if (expiresAt <= nowMs) {
|
|
138
|
+
return { ok: false, code: "expired", reason: "the grant has expired: the sandbox asks again" };
|
|
139
|
+
}
|
|
140
|
+
return { ok: true, grant: grant.data };
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// A short, readable fingerprint of a public key, for a person comparing what two screens show.
|
|
144
|
+
export const keyFingerprint = (key: string): string => {
|
|
145
|
+
const raw = Buffer.from(key, "base64url");
|
|
146
|
+
const hex = raw.subarray(0, 8).toString("hex").toUpperCase();
|
|
147
|
+
return hex.replace(/(.{4})(?=.)/gu, "$1-");
|
|
148
|
+
};
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
export const MESSAGE_LIMITS = {
|
|
6
6
|
bluesky: 300,
|
|
7
7
|
discord: 2_000,
|
|
8
|
+
facebook: 63_206,
|
|
8
9
|
instagram: 2_200,
|
|
9
10
|
linkedin: 3_000,
|
|
10
11
|
mastodon: 500,
|
|
@@ -12,6 +13,8 @@ export const MESSAGE_LIMITS = {
|
|
|
12
13
|
slack: 4_000,
|
|
13
14
|
// sendMessage refuses anything longer outright.
|
|
14
15
|
telegram: 4_096,
|
|
16
|
+
// A video's caption; a comment under one stops at 150.
|
|
17
|
+
tiktok: 4_000,
|
|
15
18
|
// A linked device (what the WhatsApp extension is) sends up to 65,536; the 4,096 often quoted is the Business API's.
|
|
16
19
|
whatsapp: 65_536,
|
|
17
20
|
x: 280,
|