@naswerks/periscope 1.0.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/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/SECURITY.md +315 -0
- package/dist/bin/command.d.ts +97 -0
- package/dist/bin/command.js +141 -0
- package/dist/bin/config.d.ts +7 -0
- package/dist/bin/config.js +90 -0
- package/dist/bin/login.d.ts +48 -0
- package/dist/bin/login.js +82 -0
- package/dist/bin/main.d.ts +33 -0
- package/dist/bin/main.js +173 -0
- package/dist/bin/pair.d.ts +28 -0
- package/dist/bin/pair.js +198 -0
- package/dist/bin/periscope.d.ts +3 -0
- package/dist/bin/periscope.js +4 -0
- package/dist/bin/posture.d.ts +64 -0
- package/dist/bin/posture.js +157 -0
- package/dist/bin/reconfigure.d.ts +64 -0
- package/dist/bin/reconfigure.js +195 -0
- package/dist/bin/serve.d.ts +103 -0
- package/dist/bin/serve.js +441 -0
- package/dist/bin/status.d.ts +11 -0
- package/dist/bin/status.js +49 -0
- package/dist/bin/workspaces.d.ts +109 -0
- package/dist/bin/workspaces.js +215 -0
- package/dist/control/backoff.d.ts +25 -0
- package/dist/control/backoff.js +20 -0
- package/dist/control/codec.d.ts +15 -0
- package/dist/control/codec.js +501 -0
- package/dist/control/credential.d.ts +23 -0
- package/dist/control/credential.js +8 -0
- package/dist/control/frames.d.ts +1180 -0
- package/dist/control/frames.js +441 -0
- package/dist/control/link-state.d.ts +32 -0
- package/dist/control/link-state.js +39 -0
- package/dist/control/link.d.ts +109 -0
- package/dist/control/link.js +736 -0
- package/dist/control/queue.d.ts +110 -0
- package/dist/control/queue.js +174 -0
- package/dist/control/seq.d.ts +49 -0
- package/dist/control/seq.js +52 -0
- package/dist/control/stream-routing.d.ts +200 -0
- package/dist/control/stream-routing.js +167 -0
- package/dist/control/stream.d.ts +43 -0
- package/dist/control/stream.js +64 -0
- package/dist/core/async-queue.d.ts +26 -0
- package/dist/core/async-queue.js +63 -0
- package/dist/core/failure.d.ts +22 -0
- package/dist/core/failure.js +53 -0
- package/dist/core/index.d.ts +18 -0
- package/dist/core/index.js +7 -0
- package/dist/core/keyed-turns.d.ts +28 -0
- package/dist/core/keyed-turns.js +44 -0
- package/dist/core/paths.d.ts +25 -0
- package/dist/core/paths.js +77 -0
- package/dist/core/refusal.d.ts +43 -0
- package/dist/core/refusal.js +399 -0
- package/dist/core/result.d.ts +22 -0
- package/dist/core/result.js +16 -0
- package/dist/core/time.d.ts +19 -0
- package/dist/core/time.js +25 -0
- package/dist/core/vocab.d.ts +23 -0
- package/dist/core/vocab.js +37 -0
- package/dist/core/workspace-id.d.ts +12 -0
- package/dist/core/workspace-id.js +12 -0
- package/dist/gate/command.d.ts +136 -0
- package/dist/gate/command.js +551 -0
- package/dist/gate/decision.d.ts +111 -0
- package/dist/gate/decision.js +97 -0
- package/dist/gate/escalate.d.ts +82 -0
- package/dist/gate/escalate.js +92 -0
- package/dist/gate/gate.d.ts +173 -0
- package/dist/gate/gate.js +417 -0
- package/dist/gate/index.d.ts +39 -0
- package/dist/gate/index.js +31 -0
- package/dist/gate/jail.d.ts +92 -0
- package/dist/gate/jail.js +131 -0
- package/dist/gate/local.d.ts +69 -0
- package/dist/gate/local.js +51 -0
- package/dist/gate/outcome.d.ts +72 -0
- package/dist/gate/outcome.js +91 -0
- package/dist/gate/shell.d.ts +36 -0
- package/dist/gate/shell.js +226 -0
- package/dist/host/agent-process.d.ts +452 -0
- package/dist/host/agent-process.js +382 -0
- package/dist/host/bulk-post.d.ts +44 -0
- package/dist/host/bulk-post.js +114 -0
- package/dist/host/claude-transcripts.d.ts +84 -0
- package/dist/host/claude-transcripts.js +339 -0
- package/dist/host/config-file.d.ts +60 -0
- package/dist/host/config-file.js +216 -0
- package/dist/host/git-facts.d.ts +7 -0
- package/dist/host/git-facts.js +106 -0
- package/dist/host/hooks.d.ts +56 -0
- package/dist/host/hooks.js +75 -0
- package/dist/host/host.d.ts +356 -0
- package/dist/host/host.js +1294 -0
- package/dist/host/index.d.ts +35 -0
- package/dist/host/index.js +22 -0
- package/dist/host/link-state-file.d.ts +18 -0
- package/dist/host/link-state-file.js +66 -0
- package/dist/host/loopback.d.ts +38 -0
- package/dist/host/loopback.js +122 -0
- package/dist/host/machine.d.ts +9 -0
- package/dist/host/machine.js +19 -0
- package/dist/host/mcp-server.d.ts +11 -0
- package/dist/host/mcp-server.js +48 -0
- package/dist/host/package-facts.d.ts +3 -0
- package/dist/host/package-facts.js +26 -0
- package/dist/host/paired-credential-store.d.ts +32 -0
- package/dist/host/paired-credential-store.js +112 -0
- package/dist/host/paths.d.ts +80 -0
- package/dist/host/paths.js +165 -0
- package/dist/host/repository-read.d.ts +33 -0
- package/dist/host/repository-read.js +201 -0
- package/dist/host/session-store.d.ts +53 -0
- package/dist/host/session-store.js +100 -0
- package/dist/host/sign-in.d.ts +90 -0
- package/dist/host/sign-in.js +239 -0
- package/dist/host/telemetry.d.ts +42 -0
- package/dist/host/telemetry.js +74 -0
- package/dist/host/token-cache.d.ts +62 -0
- package/dist/host/token-cache.js +185 -0
- package/dist/host/transcript-fs.d.ts +4 -0
- package/dist/host/transcript-fs.js +104 -0
- package/dist/host/wire-request.d.ts +80 -0
- package/dist/host/wire-request.js +196 -0
- package/dist/host/workspace-fs.d.ts +14 -0
- package/dist/host/workspace-fs.js +84 -0
- package/dist/host/workspace-trust.d.ts +23 -0
- package/dist/host/workspace-trust.js +80 -0
- package/dist/identity/authorize.d.ts +59 -0
- package/dist/identity/authorize.js +72 -0
- package/dist/identity/config.d.ts +98 -0
- package/dist/identity/config.js +130 -0
- package/dist/identity/credential.d.ts +91 -0
- package/dist/identity/credential.js +76 -0
- package/dist/identity/device-code.d.ts +115 -0
- package/dist/identity/device-code.js +134 -0
- package/dist/identity/index.d.ts +33 -0
- package/dist/identity/index.js +10 -0
- package/dist/identity/mode.d.ts +106 -0
- package/dist/identity/mode.js +69 -0
- package/dist/identity/paired-credential.d.ts +52 -0
- package/dist/identity/paired-credential.js +43 -0
- package/dist/identity/pkce.d.ts +50 -0
- package/dist/identity/pkce.js +94 -0
- package/dist/identity/store.d.ts +43 -0
- package/dist/identity/store.js +43 -0
- package/dist/identity/token.d.ts +58 -0
- package/dist/identity/token.js +149 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +53 -0
- package/dist/mcp/descriptor.d.ts +59 -0
- package/dist/mcp/descriptor.js +14 -0
- package/dist/mcp/index.d.ts +14 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/schema.d.ts +37 -0
- package/dist/mcp/schema.js +175 -0
- package/dist/mcp/server.d.ts +107 -0
- package/dist/mcp/server.js +66 -0
- package/dist/persistence/entry.d.ts +74 -0
- package/dist/persistence/entry.js +105 -0
- package/dist/persistence/index.d.ts +22 -0
- package/dist/persistence/index.js +8 -0
- package/dist/persistence/key.d.ts +46 -0
- package/dist/persistence/key.js +33 -0
- package/dist/persistence/mirror.d.ts +71 -0
- package/dist/persistence/mirror.js +57 -0
- package/dist/persistence/receipt.d.ts +111 -0
- package/dist/persistence/receipt.js +85 -0
- package/dist/persistence/retention.d.ts +68 -0
- package/dist/persistence/retention.js +68 -0
- package/dist/persistence/store.d.ts +106 -0
- package/dist/persistence/store.js +86 -0
- package/dist/persistence/transition-log.d.ts +73 -0
- package/dist/persistence/transition-log.js +133 -0
- package/dist/protocol.d.ts +27 -0
- package/dist/protocol.js +12 -0
- package/dist/sessions/index.d.ts +8 -0
- package/dist/sessions/index.js +4 -0
- package/dist/sessions/registry.d.ts +186 -0
- package/dist/sessions/registry.js +190 -0
- package/dist/sessions/session.d.ts +178 -0
- package/dist/sessions/session.js +288 -0
- package/dist/sessions/spawn-env.d.ts +88 -0
- package/dist/sessions/spawn-env.js +277 -0
- package/dist/state/coverage.d.ts +308 -0
- package/dist/state/coverage.js +315 -0
- package/dist/state/index.d.ts +12 -0
- package/dist/state/index.js +7 -0
- package/dist/state/machine.d.ts +112 -0
- package/dist/state/machine.js +237 -0
- package/dist/state/model.d.ts +233 -0
- package/dist/state/model.js +214 -0
- package/dist/state/observer.d.ts +55 -0
- package/dist/state/observer.js +413 -0
- package/dist/state/reporter.d.ts +45 -0
- package/dist/state/reporter.js +35 -0
- package/dist/state/store.d.ts +56 -0
- package/dist/state/store.js +120 -0
- package/dist/telemetry/index.d.ts +11 -0
- package/dist/telemetry/index.js +2 -0
- package/dist/telemetry/usage.d.ts +78 -0
- package/dist/telemetry/usage.js +69 -0
- package/dist/workspace/git-worktree.d.ts +150 -0
- package/dist/workspace/git-worktree.js +417 -0
- package/dist/workspace/index.d.ts +9 -0
- package/dist/workspace/index.js +4 -0
- package/dist/workspace/plain-dir.d.ts +34 -0
- package/dist/workspace/plain-dir.js +90 -0
- package/dist/workspace/provider.d.ts +152 -0
- package/dist/workspace/provider.js +2 -0
- package/dist/workspace/worktree-porcelain.d.ts +29 -0
- package/dist/workspace/worktree-porcelain.js +100 -0
- package/docs/architecture.md +277 -0
- package/docs/configuration.md +187 -0
- package/docs/gate.md +219 -0
- package/docs/identity.md +107 -0
- package/docs/protocol.md +501 -0
- package/docs/state-machine.md +160 -0
- package/examples/README.md +141 -0
- package/examples/minimal-controller/controller.ts +38 -0
- package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
- package/examples/parallel-run-proof/run.ts +850 -0
- package/examples/test-controller/controller.ts +655 -0
- package/examples/test-controller/serve.ts +29 -0
- package/examples/tsconfig.json +30 -0
- package/package.json +98 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { discriminatorOf } from '../host/agent-process.js';
|
|
2
|
+
export const MESSAGE_ROUTING = {
|
|
3
|
+
// --- delta: superseded, high-frequency, broadcast-only ---------------------
|
|
4
|
+
stream_event: {
|
|
5
|
+
lane: 'delta',
|
|
6
|
+
note: 'the incremental lane — text_delta, thinking_delta, input_json_delta, and the block start/stop markers around them. Every fragment is superseded by the assistant message that settles it, so a lost one costs a repaint. Exists at all only under includePartialMessages.',
|
|
7
|
+
},
|
|
8
|
+
'system/thinking_tokens': {
|
|
9
|
+
lane: 'delta',
|
|
10
|
+
note: 'a running thinking-token estimate, emitted per thinking delta. Its own docs call it approximate progress for spinners rather than the billed count, and the next one supersedes it.',
|
|
11
|
+
},
|
|
12
|
+
'system/status': {
|
|
13
|
+
lane: 'delta',
|
|
14
|
+
note: 'the requesting/compacting ticker. Each value supersedes the last and the transition it causes is the durable record; this lane exists so a view can show the spinner without waiting for one.',
|
|
15
|
+
},
|
|
16
|
+
tool_progress: {
|
|
17
|
+
lane: 'delta',
|
|
18
|
+
note: 'elapsed seconds for a running tool call, re-emitted while it runs. Superseded by the next tick and finally by the tool closing.',
|
|
19
|
+
},
|
|
20
|
+
'system/task_progress': {
|
|
21
|
+
lane: 'delta',
|
|
22
|
+
note: 'progress for a background task, re-emitted while it runs. Same shape as tool_progress and superseded the same way.',
|
|
23
|
+
},
|
|
24
|
+
'system/hook_progress': {
|
|
25
|
+
lane: 'delta',
|
|
26
|
+
note: 'progress while a hook handler runs — a ticker, superseded by the hook resolving.',
|
|
27
|
+
},
|
|
28
|
+
// --- update: facts of the turn that nothing later restates -----------------
|
|
29
|
+
'system/init': {
|
|
30
|
+
lane: 'update',
|
|
31
|
+
note: 'the per-spawn receipt — session id, CLI version, model, tool/skill/plugin inventory, apiKeySource. It arrives once and nothing restates it.',
|
|
32
|
+
},
|
|
33
|
+
assistant: {
|
|
34
|
+
lane: 'update',
|
|
35
|
+
note: 'the settled assistant message. The fact the deltas were fragments of; a consumer with no delta lane still renders a turn from these.',
|
|
36
|
+
},
|
|
37
|
+
user: {
|
|
38
|
+
lane: 'update',
|
|
39
|
+
note: "tool results — that is what this row carries in practice. Observed on a live session: the queued prompt does not come back on the output stream for this host's input lane, so no SDKUserMessageReplay arrives to deduplicate against. This host still synthesizes no user echo of its own, because the controller queued the prompt and already holds the operator's turn — a synthesized event would be a second name for it.",
|
|
40
|
+
},
|
|
41
|
+
result: {
|
|
42
|
+
lane: 'update',
|
|
43
|
+
note: "the turn's outcome, and the telemetry that arrives with it: total_cost_usd, modelUsage[].costUSD per model, permission_denials, ttft_ms, user_message_uuid and terminal_reason (19 values). Forwarded whole so the layer that owns cost consumes these rather than re-deriving them.",
|
|
44
|
+
},
|
|
45
|
+
rate_limit_event: {
|
|
46
|
+
lane: 'update',
|
|
47
|
+
note: 'rate-limit state with resetsAt and utilization, in-stream. Losing one loses a reset time nothing else carries.',
|
|
48
|
+
},
|
|
49
|
+
'system/compact_boundary': {
|
|
50
|
+
lane: 'update',
|
|
51
|
+
note: 'compaction happened and this is where. A durable fact about the transcript, and the boundary anything reading history has to know about.',
|
|
52
|
+
},
|
|
53
|
+
'system/api_retry': {
|
|
54
|
+
lane: 'update',
|
|
55
|
+
note: 'a request failed and is being retried. Nothing else carries it, and an unattended run that is quietly retrying looks identical to one that is thinking.',
|
|
56
|
+
},
|
|
57
|
+
'system/model_refusal_fallback': {
|
|
58
|
+
lane: 'update',
|
|
59
|
+
note: 'the model refused and a fallback ran. The counterpart of model_refusal_no_fallback, which moves the machine; this one does not, so forwarding is the only way the fact travels.',
|
|
60
|
+
},
|
|
61
|
+
'system/model_refusal_no_fallback': {
|
|
62
|
+
lane: 'update',
|
|
63
|
+
note: 'the model refused and nothing ran. It also causes a transition, but the message names the original model, which the transition does not.',
|
|
64
|
+
},
|
|
65
|
+
'system/local_command_output': {
|
|
66
|
+
lane: 'update',
|
|
67
|
+
note: 'output of a local command, which is content a consumer renders. Not restated anywhere.',
|
|
68
|
+
},
|
|
69
|
+
'system/session_state_changed': {
|
|
70
|
+
lane: 'declined',
|
|
71
|
+
note: 'its entire content is state: idle|running|requires_action, and the transition it causes carries exactly that in `to`. Forwarding it too would put two names for one fact on the wire — the reason session_started and session_ended were removed from the payload kinds.',
|
|
72
|
+
},
|
|
73
|
+
'system/task_started': {
|
|
74
|
+
lane: 'update',
|
|
75
|
+
note: 'the only message carrying task_id AND tool_use_id together — the join a consumer needs to attribute later task reports. The transition lane deliberately records nothing here.',
|
|
76
|
+
},
|
|
77
|
+
'system/task_updated': {
|
|
78
|
+
lane: 'update',
|
|
79
|
+
note: 'a task changed status or was backgrounded. It causes a transition, and it also carries the patch itself, which the transition does not.',
|
|
80
|
+
},
|
|
81
|
+
'system/task_notification': {
|
|
82
|
+
lane: 'update',
|
|
83
|
+
note: 'a background task finished and says how. Causes a transition; the message carries the status text.',
|
|
84
|
+
},
|
|
85
|
+
'system/background_tasks_changed': {
|
|
86
|
+
lane: 'update',
|
|
87
|
+
note: 'the set of background tasks changed. Nothing else enumerates them, and a session whose work moved to the background is the case this whole split exists for.',
|
|
88
|
+
},
|
|
89
|
+
'system/worker_shutting_down': {
|
|
90
|
+
lane: 'update',
|
|
91
|
+
note: 'the worker is going away, with its reason. Causes a transition; the reason is worth carrying verbatim.',
|
|
92
|
+
},
|
|
93
|
+
'system/permission_denied': {
|
|
94
|
+
lane: 'update',
|
|
95
|
+
note: "the SDK's own auto-deny short-circuit — a different path from this host's gate, which the SDK's docs say explicitly does not produce this message. Nothing this package emits restates it.",
|
|
96
|
+
},
|
|
97
|
+
'system/elicitation_complete': {
|
|
98
|
+
lane: 'update',
|
|
99
|
+
note: 'an MCP elicitation resolved, with what it resolved to. The transition records the exit; the answer rides here.',
|
|
100
|
+
},
|
|
101
|
+
'system/plugin_install': {
|
|
102
|
+
lane: 'update',
|
|
103
|
+
note: 'a plugin was installed mid-session, so the inventory init reported is now stale. Nothing else says so.',
|
|
104
|
+
},
|
|
105
|
+
'system/commands_changed': {
|
|
106
|
+
lane: 'update',
|
|
107
|
+
note: 'the available command set changed. init carries the opening inventory and this carries the change; a consumer showing commands has no other source.',
|
|
108
|
+
},
|
|
109
|
+
'system/notification': {
|
|
110
|
+
lane: 'update',
|
|
111
|
+
note: 'a notification aimed at whoever is watching. Withholding it would be this host deciding what a controller finds worth showing.',
|
|
112
|
+
},
|
|
113
|
+
'system/memory_recall': {
|
|
114
|
+
lane: 'update',
|
|
115
|
+
note: 'what the agent recalled and used. Content, and part of explaining a turn a reader is trying to understand.',
|
|
116
|
+
},
|
|
117
|
+
'system/mirror_error': {
|
|
118
|
+
lane: 'update',
|
|
119
|
+
note: 'a mirror write failed. A degrade is a named outcome and never a silent pass, so it goes on the wire even though this host takes no action on it.',
|
|
120
|
+
},
|
|
121
|
+
'system/informational': {
|
|
122
|
+
lane: 'update',
|
|
123
|
+
note: "an informational message from the agent. Forwarded for the same reason as notification: the judgement is the controller's.",
|
|
124
|
+
},
|
|
125
|
+
tool_use_summary: {
|
|
126
|
+
lane: 'update',
|
|
127
|
+
note: 'a settled summary of a tool call — what a cold reader sees instead of raw arguments.',
|
|
128
|
+
},
|
|
129
|
+
auth_status: {
|
|
130
|
+
lane: 'update',
|
|
131
|
+
note: 'the credential state changed under a running session. An unattended run whose auth lapsed must not look like one that went quiet.',
|
|
132
|
+
},
|
|
133
|
+
prompt_suggestion: {
|
|
134
|
+
lane: 'update',
|
|
135
|
+
note: "a suggested next turn. Product surface, and whether to show it is the controller's call rather than this host's.",
|
|
136
|
+
},
|
|
137
|
+
conversation_reset: {
|
|
138
|
+
lane: 'update',
|
|
139
|
+
note: 'the conversation was reset, so everything a consumer has accumulated for this session is now history rather than context.',
|
|
140
|
+
},
|
|
141
|
+
// --- declined: the fact reaches the wire another way, or it is the host's own ---
|
|
142
|
+
'system/control_request_progress': {
|
|
143
|
+
lane: 'declined',
|
|
144
|
+
note: "progress on the SDK's internal control protocol between this host and its own CLI subprocess. It describes the host's transport, not the session, and a controller can do nothing with it.",
|
|
145
|
+
},
|
|
146
|
+
'system/hook_started': {
|
|
147
|
+
lane: 'declined',
|
|
148
|
+
note: "reports one of this host's own hooks starting. Forwarding it would echo the observer and the gate back at the controller that configured them. The gate's outcomes are recorded as transitions in the machine, and forwardSession's machine subscription carries every recorded transition to the wire whatever caused it — so this message would arrive as a second copy of a fact the transition already states.",
|
|
149
|
+
},
|
|
150
|
+
'system/hook_response': {
|
|
151
|
+
lane: 'declined',
|
|
152
|
+
note: "the answer this host's own hook just returned. Same echo reason as hook_started — and the decision's transition already rides the wire via the machine subscription, so re-reporting the answer here would invite a consumer to count one decision twice.",
|
|
153
|
+
},
|
|
154
|
+
'system/files_persisted': {
|
|
155
|
+
lane: 'declined',
|
|
156
|
+
note: 'a checkpointing receipt. It belongs to the layer that owns the receipt read path, which reads local records rather than the live stream.',
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
/** The lane one message rides. Total by construction — every discriminator has a row. */
|
|
160
|
+
export function laneFor(message) {
|
|
161
|
+
return MESSAGE_ROUTING[discriminatorOf(message)].lane;
|
|
162
|
+
}
|
|
163
|
+
/** Every discriminator on one lane, in declaration order. The subject of the routing pin. */
|
|
164
|
+
export function discriminatorsOn(lane) {
|
|
165
|
+
return Object.keys(MESSAGE_ROUTING).filter((key) => MESSAGE_ROUTING[key].lane === lane);
|
|
166
|
+
}
|
|
167
|
+
//# sourceMappingURL=stream-routing.js.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { HostedSession, Unsubscribe } from '../sessions/session.js';
|
|
2
|
+
import type { SessionObserver } from '../state/observer.js';
|
|
3
|
+
import type { Refusal } from '../core/refusal.js';
|
|
4
|
+
import type { Result } from '../core/result.js';
|
|
5
|
+
import type { SessionPayload } from './frames.js';
|
|
6
|
+
/**
|
|
7
|
+
* Where frames go. Structural rather than `ControllerLink` so the forwarder can be exercised with
|
|
8
|
+
* no socket — and so this module pulls no WebSocket dependency into a path that does not need one.
|
|
9
|
+
*/
|
|
10
|
+
export interface FrameSink {
|
|
11
|
+
send(sessionId: string, payload: SessionPayload): Result<void>;
|
|
12
|
+
}
|
|
13
|
+
export interface ForwardSessionOptions {
|
|
14
|
+
/**
|
|
15
|
+
* The frame's routing key: the controller's handle for this session, not the agent's own id.
|
|
16
|
+
*
|
|
17
|
+
* See `SessionFrame` in frames.ts for the three ids and why they are not interchangeable. The
|
|
18
|
+
* short version: a session's first transitions are recorded before the agent has named itself,
|
|
19
|
+
* and a session that dies in start-up never names itself at all, so keying frames by the agent's
|
|
20
|
+
* id would make exactly the frames that explain a failure unsendable.
|
|
21
|
+
*/
|
|
22
|
+
readonly sessionKey: string;
|
|
23
|
+
readonly session: HostedSession;
|
|
24
|
+
/** The session's own observer. Already attached to its machine; this does not create one. */
|
|
25
|
+
readonly observer: SessionObserver;
|
|
26
|
+
readonly sink: FrameSink;
|
|
27
|
+
/**
|
|
28
|
+
* Every refusal the sink returned, and every transition the machine would not record.
|
|
29
|
+
*
|
|
30
|
+
* Wired by callers that want to know; a refusal that nobody listens to is still a refusal and is
|
|
31
|
+
* never a silent success — `send` has already declined by the time this is called.
|
|
32
|
+
*/
|
|
33
|
+
readonly onRefusal?: (refusal: Refusal) => void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Start forwarding. Returns the unsubscribe, which is idempotent.
|
|
37
|
+
*
|
|
38
|
+
* The end transition is emitted from the session's own end listener rather than from the message
|
|
39
|
+
* stream, because a session can end without its stream ending (a start timeout, a stop request)
|
|
40
|
+
* and a trace whose last row is whatever happened to arrive last is not a trace of an ending.
|
|
41
|
+
*/
|
|
42
|
+
export declare function forwardSession(options: ForwardSessionOptions): Unsubscribe;
|
|
43
|
+
//# sourceMappingURL=stream.d.ts.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { agentMessageDelta, agentMessageUpdate, stateTransitionUpdate } from './frames.js';
|
|
2
|
+
import { laneFor } from './stream-routing.js';
|
|
3
|
+
/**
|
|
4
|
+
* Start forwarding. Returns the unsubscribe, which is idempotent.
|
|
5
|
+
*
|
|
6
|
+
* The end transition is emitted from the session's own end listener rather than from the message
|
|
7
|
+
* stream, because a session can end without its stream ending (a start timeout, a stop request)
|
|
8
|
+
* and a trace whose last row is whatever happened to arrive last is not a trace of an ending.
|
|
9
|
+
*/
|
|
10
|
+
export function forwardSession(options) {
|
|
11
|
+
const { sessionKey, session, observer, sink } = options;
|
|
12
|
+
const refused = (refusal) => options.onRefusal?.(refusal);
|
|
13
|
+
const emit = (payload) => {
|
|
14
|
+
const sent = sink.send(sessionKey, payload);
|
|
15
|
+
if (!sent.ok)
|
|
16
|
+
refused(sent.refusal);
|
|
17
|
+
};
|
|
18
|
+
// Every transition this session records, whichever lane produced it. See this file's header.
|
|
19
|
+
const dropTransitions = observer.machine.onTransition((transition) => emit(stateTransitionUpdate(transition)));
|
|
20
|
+
/**
|
|
21
|
+
* A transition the machine refused is not on the wire and never will be, so this is the only
|
|
22
|
+
* place it can be named. The recorded ones are already gone out through the subscription above,
|
|
23
|
+
* which is why nothing here emits.
|
|
24
|
+
*/
|
|
25
|
+
const reportRejected = (recorded) => {
|
|
26
|
+
for (const result of recorded)
|
|
27
|
+
if (!result.ok)
|
|
28
|
+
refused(result.refusal);
|
|
29
|
+
};
|
|
30
|
+
const dropMessages = session.onMessage((message) => {
|
|
31
|
+
// The message first, then what it caused. See this file's header.
|
|
32
|
+
forwardMessage(message, emit);
|
|
33
|
+
reportRejected(observer.observeMessage(message));
|
|
34
|
+
});
|
|
35
|
+
const dropEnd = session.onEnd((ended) => {
|
|
36
|
+
reportRejected([observer.ended({ kind: 'process', event: ended.cause, detail: ended.detail })]);
|
|
37
|
+
dropMessages();
|
|
38
|
+
});
|
|
39
|
+
return () => {
|
|
40
|
+
dropMessages();
|
|
41
|
+
dropEnd();
|
|
42
|
+
dropTransitions();
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* One message onto its declared lane, or nowhere.
|
|
47
|
+
*
|
|
48
|
+
* A large message is refused, not truncated, and it will happen: a tool result carrying a big
|
|
49
|
+
* file exceeds the frame limit, `send` declines with `frame-too-large`, and the refusal names the
|
|
50
|
+
* bulk lane. That is the "commands only, never payloads" rule doing its job rather than a defect,
|
|
51
|
+
* but a consumer will see a turn whose largest tool result never arrived, so the refusal has to
|
|
52
|
+
* reach someone. It does, through `onRefusal`.
|
|
53
|
+
*/
|
|
54
|
+
function forwardMessage(message, emit) {
|
|
55
|
+
const lane = laneFor(message);
|
|
56
|
+
if (lane === 'declined')
|
|
57
|
+
return;
|
|
58
|
+
// Cast rather than copy: the message arrived as JSON and the codec is where bytes and types meet,
|
|
59
|
+
// so re-validating it here would be a second wire edge. A message that genuinely cannot serialize
|
|
60
|
+
// is refused by `encode` under its own name and reaches `onRefusal` like any other refusal.
|
|
61
|
+
const body = message;
|
|
62
|
+
emit(lane === 'delta' ? agentMessageDelta(body) : agentMessageUpdate(body));
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=stream.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single-consumer async queue: push values in, iterate them out, close when there are no more.
|
|
3
|
+
*
|
|
4
|
+
* It exists because a session's input has to be an `AsyncIterable` before the first turn is known —
|
|
5
|
+
* the agent process is started with a stream it will read from later, and the control surface
|
|
6
|
+
* (interrupt, model change, permission mode) is only available to a session driven that way. A
|
|
7
|
+
* plain string prompt would ship a session that cannot be interrupted.
|
|
8
|
+
*
|
|
9
|
+
* Pure and generic on purpose: nothing here knows what a message is, so it is testable without a
|
|
10
|
+
* process and reusable by anything else that needs the same shape.
|
|
11
|
+
*/
|
|
12
|
+
export declare class AsyncQueue<T> implements AsyncIterable<T> {
|
|
13
|
+
#private;
|
|
14
|
+
/** Hand a value to the consumer, or hold it until one asks. Ignored after `end()`. */
|
|
15
|
+
push(item: T): void;
|
|
16
|
+
/**
|
|
17
|
+
* No more values. Idempotent, and it releases every waiting consumer rather than leaving them
|
|
18
|
+
* hanging — an iterator that never returns is indistinguishable from a session that never ends.
|
|
19
|
+
*/
|
|
20
|
+
end(): void;
|
|
21
|
+
/** Values pushed but not yet taken. */
|
|
22
|
+
get depth(): number;
|
|
23
|
+
get ended(): boolean;
|
|
24
|
+
[Symbol.asyncIterator](): AsyncIterator<T>;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=async-queue.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single-consumer async queue: push values in, iterate them out, close when there are no more.
|
|
3
|
+
*
|
|
4
|
+
* It exists because a session's input has to be an `AsyncIterable` before the first turn is known —
|
|
5
|
+
* the agent process is started with a stream it will read from later, and the control surface
|
|
6
|
+
* (interrupt, model change, permission mode) is only available to a session driven that way. A
|
|
7
|
+
* plain string prompt would ship a session that cannot be interrupted.
|
|
8
|
+
*
|
|
9
|
+
* Pure and generic on purpose: nothing here knows what a message is, so it is testable without a
|
|
10
|
+
* process and reusable by anything else that needs the same shape.
|
|
11
|
+
*/
|
|
12
|
+
export class AsyncQueue {
|
|
13
|
+
#items = [];
|
|
14
|
+
#waiting = [];
|
|
15
|
+
#ended = false;
|
|
16
|
+
/** Hand a value to the consumer, or hold it until one asks. Ignored after `end()`. */
|
|
17
|
+
push(item) {
|
|
18
|
+
if (this.#ended)
|
|
19
|
+
return;
|
|
20
|
+
const waiter = this.#waiting.shift();
|
|
21
|
+
if (waiter !== undefined) {
|
|
22
|
+
waiter({ value: item, done: false });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
this.#items.push(item);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* No more values. Idempotent, and it releases every waiting consumer rather than leaving them
|
|
29
|
+
* hanging — an iterator that never returns is indistinguishable from a session that never ends.
|
|
30
|
+
*/
|
|
31
|
+
end() {
|
|
32
|
+
if (this.#ended)
|
|
33
|
+
return;
|
|
34
|
+
this.#ended = true;
|
|
35
|
+
while (this.#waiting.length > 0) {
|
|
36
|
+
this.#waiting.shift()?.({ value: undefined, done: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Values pushed but not yet taken. */
|
|
40
|
+
get depth() {
|
|
41
|
+
return this.#items.length;
|
|
42
|
+
}
|
|
43
|
+
get ended() {
|
|
44
|
+
return this.#ended;
|
|
45
|
+
}
|
|
46
|
+
[Symbol.asyncIterator]() {
|
|
47
|
+
return {
|
|
48
|
+
next: () => {
|
|
49
|
+
const item = this.#items.shift();
|
|
50
|
+
if (item !== undefined)
|
|
51
|
+
return Promise.resolve({ value: item, done: false });
|
|
52
|
+
if (this.#ended)
|
|
53
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
54
|
+
return new Promise((resolve) => this.#waiting.push(resolve));
|
|
55
|
+
},
|
|
56
|
+
return: () => {
|
|
57
|
+
this.end();
|
|
58
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=async-queue.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Describing a thrown failure so the reader learns the cause, not the wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Node's `fetch` throws `TypeError: fetch failed` and puts the reason on `error.cause`; a WebSocket
|
|
5
|
+
* carries a `code` such as `DEPTH_ZERO_SELF_SIGNED_CERT`. A message that stops at the wrapper reads
|
|
6
|
+
* "could not be reached" for a controller that answered the TCP dial and refused nothing but its own
|
|
7
|
+
* certificate. So the nested causes are walked, the innermost code is named, and a certificate refusal
|
|
8
|
+
* carries the remedy, because it is the failure a local controller meets first.
|
|
9
|
+
*/
|
|
10
|
+
/** Is this failure TLS refusing the peer's certificate? Read off every `code` met on the way down. */
|
|
11
|
+
export declare function isCertificateRefusal(error: unknown): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* The failure in one line: the innermost message, prefixed by the innermost code when there is one.
|
|
14
|
+
* `fetch failed` becomes `DEPTH_ZERO_SELF_SIGNED_CERT: self-signed certificate`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function describeFailure(error: unknown): string;
|
|
17
|
+
/**
|
|
18
|
+
* What to do about a certificate refusal, in the operator's words. Stated once, here, so the pair
|
|
19
|
+
* verb, the decision transport and the link say the same thing.
|
|
20
|
+
*/
|
|
21
|
+
export declare function certificateRemedy(where: string): string;
|
|
22
|
+
//# sourceMappingURL=failure.d.ts.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Describing a thrown failure so the reader learns the cause, not the wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Node's `fetch` throws `TypeError: fetch failed` and puts the reason on `error.cause`; a WebSocket
|
|
5
|
+
* carries a `code` such as `DEPTH_ZERO_SELF_SIGNED_CERT`. A message that stops at the wrapper reads
|
|
6
|
+
* "could not be reached" for a controller that answered the TCP dial and refused nothing but its own
|
|
7
|
+
* certificate. So the nested causes are walked, the innermost code is named, and a certificate refusal
|
|
8
|
+
* carries the remedy, because it is the failure a local controller meets first.
|
|
9
|
+
*/
|
|
10
|
+
/** The `code` a Node error carries when TLS refused the peer's certificate. */
|
|
11
|
+
const CERTIFICATE_CODES = /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|CERTIFICATE/;
|
|
12
|
+
/** The innermost error under nested `cause` members, and the codes met on the way down. */
|
|
13
|
+
function unwrap(error) {
|
|
14
|
+
const codes = [];
|
|
15
|
+
let current = error;
|
|
16
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
17
|
+
if (typeof current === 'object' &&
|
|
18
|
+
current !== null &&
|
|
19
|
+
typeof current.code === 'string') {
|
|
20
|
+
codes.push(current.code);
|
|
21
|
+
}
|
|
22
|
+
const next = typeof current === 'object' && current !== null ? current.cause : undefined;
|
|
23
|
+
if (next === undefined || next === null)
|
|
24
|
+
break;
|
|
25
|
+
current = next;
|
|
26
|
+
}
|
|
27
|
+
return { leaf: current, codes };
|
|
28
|
+
}
|
|
29
|
+
/** Is this failure TLS refusing the peer's certificate? Read off every `code` met on the way down. */
|
|
30
|
+
export function isCertificateRefusal(error) {
|
|
31
|
+
return unwrap(error).codes.some((code) => CERTIFICATE_CODES.test(code));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The failure in one line: the innermost message, prefixed by the innermost code when there is one.
|
|
35
|
+
* `fetch failed` becomes `DEPTH_ZERO_SELF_SIGNED_CERT: self-signed certificate`.
|
|
36
|
+
*/
|
|
37
|
+
export function describeFailure(error) {
|
|
38
|
+
const { leaf, codes } = unwrap(error);
|
|
39
|
+
const message = leaf instanceof Error ? leaf.message : typeof leaf === 'string' ? leaf : String(leaf);
|
|
40
|
+
const code = codes.at(-1);
|
|
41
|
+
return code === undefined || message.includes(code) ? message : `${code}: ${message}`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* What to do about a certificate refusal, in the operator's words. Stated once, here, so the pair
|
|
45
|
+
* verb, the decision transport and the link say the same thing.
|
|
46
|
+
*/
|
|
47
|
+
export function certificateRemedy(where) {
|
|
48
|
+
return (`Node does not trust the certificate ${where} presents. For a controller on a development ` +
|
|
49
|
+
`certificate, export that certificate as PEM and point Node at it: set NODE_EXTRA_CA_CERTS to the ` +
|
|
50
|
+
`file's path, then run this command again in a new terminal. Disabling verification ` +
|
|
51
|
+
`(NODE_TLS_REJECT_UNAUTHORIZED=0) also works and trusts every certificate; prefer the file.`);
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=failure.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure core: no `node:` imports, no packages, no I/O.
|
|
3
|
+
*
|
|
4
|
+
* Pinned by src/pins/core-purity.test.ts. The point is that anything here runs anywhere — in the
|
|
5
|
+
* host, in a controller, in a browser — so the contract types can be shared without dragging a
|
|
6
|
+
* runtime along with them.
|
|
7
|
+
*/
|
|
8
|
+
export type { Refusal, RefusalReason } from './refusal.js';
|
|
9
|
+
export { REFUSAL_REASONS, isRefusalReason, refusal } from './refusal.js';
|
|
10
|
+
export type { Result } from './result.js';
|
|
11
|
+
export { isOk, ok, refuse, valueOr } from './result.js';
|
|
12
|
+
export type { HostNoun, SdkNoun } from './vocab.js';
|
|
13
|
+
export { HOST_NOUNS, SDK_NOUNS, isDeclaredNoun, nounOf } from './vocab.js';
|
|
14
|
+
export { isAbsolutePath, isContainedBy, normalizePath, requireAbsolute } from './paths.js';
|
|
15
|
+
export { MAX_WORKSPACE_ID_LENGTH } from './workspace-id.js';
|
|
16
|
+
export type { Clock, Ticker } from './time.js';
|
|
17
|
+
export { fixedClock, fixedTicker, systemClock, systemTicker } from './time.js';
|
|
18
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { REFUSAL_REASONS, isRefusalReason, refusal } from './refusal.js';
|
|
2
|
+
export { isOk, ok, refuse, valueOr } from './result.js';
|
|
3
|
+
export { HOST_NOUNS, SDK_NOUNS, isDeclaredNoun, nounOf } from './vocab.js';
|
|
4
|
+
export { isAbsolutePath, isContainedBy, normalizePath, requireAbsolute } from './paths.js';
|
|
5
|
+
export { MAX_WORKSPACE_ID_LENGTH } from './workspace-id.js';
|
|
6
|
+
export { fixedClock, fixedTicker, systemClock, systemTicker } from './time.js';
|
|
7
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A per-key serial executor: work submitted under one key runs one piece at a time, in arrival
|
|
3
|
+
* order; work under different keys interleaves freely.
|
|
4
|
+
*
|
|
5
|
+
* It exists because the host's workspace verbs are fire-and-forget concurrent (`#dispatch` never
|
|
6
|
+
* awaits a handler) while the work they do takes seconds and is mutually destructive on one key:
|
|
7
|
+
* a `git worktree add` and a `git worktree remove --force` each take many seconds, and the
|
|
8
|
+
* reachable interleavings are real — a provision landing inside a removal, a removal landing inside
|
|
9
|
+
* a close's release. Serializing per key closes every one of those windows without making
|
|
10
|
+
* unrelated workspaces wait on each other.
|
|
11
|
+
*
|
|
12
|
+
* Pure and generic on purpose: nothing here knows what a workspace is, so it is testable without
|
|
13
|
+
* a provider and reusable by anything else that needs the same shape.
|
|
14
|
+
*/
|
|
15
|
+
export declare class KeyedTurns {
|
|
16
|
+
#private;
|
|
17
|
+
/**
|
|
18
|
+
* Run `work` after everything previously submitted under `key` has settled.
|
|
19
|
+
*
|
|
20
|
+
* The returned promise carries `work`'s own result or rejection. A rejection does not poison the
|
|
21
|
+
* key: the next submission runs regardless, because a failed removal must not wedge every future
|
|
22
|
+
* provision at that key behind an error nobody can clear.
|
|
23
|
+
*/
|
|
24
|
+
run<T>(key: string, work: () => Promise<T>): Promise<T>;
|
|
25
|
+
/** Keys with unsettled work — an observability convenience, never a guard. */
|
|
26
|
+
get depth(): number;
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=keyed-turns.d.ts.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A per-key serial executor: work submitted under one key runs one piece at a time, in arrival
|
|
3
|
+
* order; work under different keys interleaves freely.
|
|
4
|
+
*
|
|
5
|
+
* It exists because the host's workspace verbs are fire-and-forget concurrent (`#dispatch` never
|
|
6
|
+
* awaits a handler) while the work they do takes seconds and is mutually destructive on one key:
|
|
7
|
+
* a `git worktree add` and a `git worktree remove --force` each take many seconds, and the
|
|
8
|
+
* reachable interleavings are real — a provision landing inside a removal, a removal landing inside
|
|
9
|
+
* a close's release. Serializing per key closes every one of those windows without making
|
|
10
|
+
* unrelated workspaces wait on each other.
|
|
11
|
+
*
|
|
12
|
+
* Pure and generic on purpose: nothing here knows what a workspace is, so it is testable without
|
|
13
|
+
* a provider and reusable by anything else that needs the same shape.
|
|
14
|
+
*/
|
|
15
|
+
export class KeyedTurns {
|
|
16
|
+
/** The tail of each key's queue — the promise the next submission must wait behind. */
|
|
17
|
+
#tails = new Map();
|
|
18
|
+
/**
|
|
19
|
+
* Run `work` after everything previously submitted under `key` has settled.
|
|
20
|
+
*
|
|
21
|
+
* The returned promise carries `work`'s own result or rejection. A rejection does not poison the
|
|
22
|
+
* key: the next submission runs regardless, because a failed removal must not wedge every future
|
|
23
|
+
* provision at that key behind an error nobody can clear.
|
|
24
|
+
*/
|
|
25
|
+
async run(key, work) {
|
|
26
|
+
const previous = this.#tails.get(key) ?? Promise.resolve();
|
|
27
|
+
const turn = previous.then(work, work);
|
|
28
|
+
// The tail entry is removed when this turn settles with nothing queued behind it. The map
|
|
29
|
+
// identity check is what makes the cleanup safe under concurrent submission: a newer tail
|
|
30
|
+
// means a later submission queued behind this one, and the entry is now that one's to clean up.
|
|
31
|
+
const tail = turn.then(() => undefined, () => undefined);
|
|
32
|
+
this.#tails.set(key, tail);
|
|
33
|
+
void tail.then(() => {
|
|
34
|
+
if (this.#tails.get(key) === tail)
|
|
35
|
+
this.#tails.delete(key);
|
|
36
|
+
});
|
|
37
|
+
return turn;
|
|
38
|
+
}
|
|
39
|
+
/** Keys with unsettled work — an observability convenience, never a guard. */
|
|
40
|
+
get depth() {
|
|
41
|
+
return this.#tails.size;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=keyed-turns.js.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path handling, written without `node:path` because this module is import-free by contract.
|
|
3
|
+
*
|
|
4
|
+
* Absolute always, on the wire and at every boundary — a relative path means "relative to a cwd the
|
|
5
|
+
* other end cannot see", which is a bug waiting for a second machine.
|
|
6
|
+
*/
|
|
7
|
+
import type { Result } from './result.js';
|
|
8
|
+
/** POSIX `/x`, Windows `C:\x`, or a UNC `\\server\share`. */
|
|
9
|
+
export declare function isAbsolutePath(candidate: string): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Separators to `/`, redundant separators collapsed, `.` dropped and `..` resolved textually.
|
|
12
|
+
*
|
|
13
|
+
* Textual resolution deliberately does NOT consult the filesystem, so it cannot follow a symlink.
|
|
14
|
+
* A caller enforcing a real jail must canonicalize through the filesystem first — see
|
|
15
|
+
* `host/`, which is the only module that can.
|
|
16
|
+
*/
|
|
17
|
+
export declare function normalizePath(input: string): string;
|
|
18
|
+
/** Normalizes, and refuses anything not absolute. The shape every boundary should take. */
|
|
19
|
+
export declare function requireAbsolute(candidate: string): Result<string>;
|
|
20
|
+
/**
|
|
21
|
+
* Is `candidate` at or beneath `root`? Both are normalized first; the comparison is
|
|
22
|
+
* segment-wise, so `/a/bc` is NOT inside `/a/b`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function isContainedBy(candidate: string, root: string): boolean;
|
|
25
|
+
//# sourceMappingURL=paths.d.ts.map
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { ok, refuse } from './result.js';
|
|
2
|
+
const WINDOWS_DRIVE = /^[A-Za-z]:[\\/]/;
|
|
3
|
+
/** POSIX `/x`, Windows `C:\x`, or a UNC `\\server\share`. */
|
|
4
|
+
export function isAbsolutePath(candidate) {
|
|
5
|
+
if (candidate.startsWith('/'))
|
|
6
|
+
return true;
|
|
7
|
+
if (candidate.startsWith('\\\\'))
|
|
8
|
+
return true;
|
|
9
|
+
return WINDOWS_DRIVE.test(candidate);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Separators to `/`, redundant separators collapsed, `.` dropped and `..` resolved textually.
|
|
13
|
+
*
|
|
14
|
+
* Textual resolution deliberately does NOT consult the filesystem, so it cannot follow a symlink.
|
|
15
|
+
* A caller enforcing a real jail must canonicalize through the filesystem first — see
|
|
16
|
+
* `host/`, which is the only module that can.
|
|
17
|
+
*/
|
|
18
|
+
export function normalizePath(input) {
|
|
19
|
+
const isUnc = input.startsWith('\\\\');
|
|
20
|
+
const unified = input.replace(/\\/g, '/');
|
|
21
|
+
const hasDrive = WINDOWS_DRIVE.test(input);
|
|
22
|
+
const rooted = unified.startsWith('/') || hasDrive || isUnc;
|
|
23
|
+
const segments = [];
|
|
24
|
+
let prefix = '';
|
|
25
|
+
let body = unified;
|
|
26
|
+
if (hasDrive) {
|
|
27
|
+
prefix = `${unified.slice(0, 2)}/`;
|
|
28
|
+
body = unified.slice(3);
|
|
29
|
+
}
|
|
30
|
+
else if (isUnc) {
|
|
31
|
+
prefix = '//';
|
|
32
|
+
body = unified.slice(2);
|
|
33
|
+
}
|
|
34
|
+
else if (rooted) {
|
|
35
|
+
prefix = '/';
|
|
36
|
+
body = unified.slice(1);
|
|
37
|
+
}
|
|
38
|
+
for (const segment of body.split('/')) {
|
|
39
|
+
if (segment === '' || segment === '.')
|
|
40
|
+
continue;
|
|
41
|
+
if (segment === '..') {
|
|
42
|
+
if (segments.length > 0 && segments[segments.length - 1] !== '..') {
|
|
43
|
+
segments.pop();
|
|
44
|
+
}
|
|
45
|
+
else if (!rooted) {
|
|
46
|
+
segments.push('..');
|
|
47
|
+
}
|
|
48
|
+
// Rooted paths cannot climb above their root; `/..` is `/`.
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
segments.push(segment);
|
|
52
|
+
}
|
|
53
|
+
const joined = segments.join('/');
|
|
54
|
+
if (prefix === '')
|
|
55
|
+
return joined === '' ? '.' : joined;
|
|
56
|
+
return prefix + joined;
|
|
57
|
+
}
|
|
58
|
+
/** Normalizes, and refuses anything not absolute. The shape every boundary should take. */
|
|
59
|
+
export function requireAbsolute(candidate) {
|
|
60
|
+
if (!isAbsolutePath(candidate)) {
|
|
61
|
+
return refuse('path-not-absolute', `not an absolute path: ${candidate}`);
|
|
62
|
+
}
|
|
63
|
+
return ok(normalizePath(candidate));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Is `candidate` at or beneath `root`? Both are normalized first; the comparison is
|
|
67
|
+
* segment-wise, so `/a/bc` is NOT inside `/a/b`.
|
|
68
|
+
*/
|
|
69
|
+
export function isContainedBy(candidate, root) {
|
|
70
|
+
const normalizedRoot = normalizePath(root);
|
|
71
|
+
const normalizedCandidate = normalizePath(candidate);
|
|
72
|
+
if (normalizedCandidate === normalizedRoot)
|
|
73
|
+
return true;
|
|
74
|
+
const boundary = normalizedRoot.endsWith('/') ? normalizedRoot : `${normalizedRoot}/`;
|
|
75
|
+
return normalizedCandidate.startsWith(boundary);
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=paths.js.map
|