granttap-mcp 0.8.79 → 0.8.81
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 +108 -6
- package/cursor-plugin/README.md +1 -1
- package/cursor-plugin/mcp.json +1 -1
- package/cursor-plugin/stdio-bootstrap.cjs +1 -1
- package/package.json +1 -1
- package/packages/core/relay-client.ts +17 -23
- package/packages/core/relay-envelope.ts +8 -1
- package/packages/core/socket-lifecycle/README.md +9 -0
- package/packages/core/socket-lifecycle/index.ts +1 -0
- package/packages/core/socket-lifecycle/reconnect-backoff.ts +29 -0
- package/packages/core/socket-lifecycle/upgrade-headers.ts +1 -2
package/README.md
CHANGED
|
@@ -32,11 +32,11 @@ checkout. Received previews remain on the device for offline inspection.
|
|
|
32
32
|
Messages sent to a chat open in Codex Desktop continue that same conversation,
|
|
33
33
|
including while a turn is running. The runtime forwards them to the existing
|
|
34
34
|
desktop owner and preserves the delivery ID across retries. **Pause** reaches
|
|
35
|
-
that owner too. A
|
|
36
|
-
then arrives through the existing chat history.
|
|
35
|
+
that owner too. A provider acceptance receipt means Codex accepted the message; its answer
|
|
36
|
+
then arrives through the existing chat history. Relay storage is a separate stage.
|
|
37
37
|
|
|
38
38
|
The MCP runtime, plugins, and CLI use the GrantTap Commercial Source License.
|
|
39
|
-
|
|
39
|
+
Managed service use requires authorized GrantTap account access; official TestFlight
|
|
40
40
|
access remains free. Earlier releases through 0.8.75 retain their Apache-2.0
|
|
41
41
|
permissions. The Apple apps, Engine, relay, and website have their own terms.
|
|
42
42
|
|
|
@@ -44,6 +44,76 @@ permissions. The Apple apps, Engine, relay, and website have their own terms.
|
|
|
44
44
|
[Security model](SECURITY.md) ·
|
|
45
45
|
[Relay source](https://github.com/sergii-ziborov/granttap-relay)
|
|
46
46
|
|
|
47
|
+
## Start here
|
|
48
|
+
|
|
49
|
+
- [Connect your computer and devices](#account-mesh-and-devices)
|
|
50
|
+
- [Choose a connection mode and plan](#plans-and-what-you-pay-for)
|
|
51
|
+
- [Provider support](#supported-providers)
|
|
52
|
+
- [Install provider integrations](#install)
|
|
53
|
+
- [Messages, queues and decisions](#messages-queues-and-decisions)
|
|
54
|
+
- [Project Mesh and task transfer](#project-mesh-and-task-handoff)
|
|
55
|
+
- [Approvals and questions](#decisions-and-claude-questions)
|
|
56
|
+
- [Security and relay boundary](#relay-boundary)
|
|
57
|
+
- [Troubleshooting and help](#troubleshooting-and-help)
|
|
58
|
+
- [License](#license)
|
|
59
|
+
|
|
60
|
+
## Plans and what you pay for
|
|
61
|
+
|
|
62
|
+
Planned US prices for the first commercial release:
|
|
63
|
+
|
|
64
|
+
| Plan | Monthly price | Managed publishing computers |
|
|
65
|
+
| --- | ---: | ---: |
|
|
66
|
+
| Personal | $4.99 | 2 |
|
|
67
|
+
| Personal Plus | $7.99 | 5 |
|
|
68
|
+
| Fleet | $13.99 | 15 |
|
|
69
|
+
|
|
70
|
+
The same core GrantTap features are included in every monthly plan. A computer
|
|
71
|
+
running several supported agents still uses one publishing-computer seat.
|
|
72
|
+
iPhone, iPad, Apple Watch and Macs used only as controllers do not use additional
|
|
73
|
+
execution-computer seats. Fleet is a larger personal allowance, without an
|
|
74
|
+
enterprise SLA or a per-person license.
|
|
75
|
+
|
|
76
|
+
An eligible new Apple subscription starts with **7 days free**, then renews
|
|
77
|
+
monthly at the price shown by Apple. To avoid renewal, cancel at least 24 hours
|
|
78
|
+
before the trial ends in Apple's subscription settings.
|
|
79
|
+
A local cache is not deleted when managed access ends.
|
|
80
|
+
|
|
81
|
+
**Official TestFlight builds include all features without a purchase or
|
|
82
|
+
subscription.** A trial and TestFlight access are different forms of access.
|
|
83
|
+
Paid products are still being prepared for commercial launch; the App Store
|
|
84
|
+
purchase sheet determines availability, localized prices, eligibility and terms.
|
|
85
|
+
Models, AI tokens and coding-provider plans are purchased separately.
|
|
86
|
+
|
|
87
|
+
**Mac Local is a separate $39.99 one-time purchase** for local control and an
|
|
88
|
+
own relay. Hosted managed delivery is not included in that purchase. An active
|
|
89
|
+
Personal subscription already covers a managed Mac client; both purchases are
|
|
90
|
+
not required for the same managed mode. npm distributes the computer runtime;
|
|
91
|
+
it does not sell an independent subscription or unlock a hosted entitlement.
|
|
92
|
+
|
|
93
|
+
See [current pricing](https://granttap.com/pricing),
|
|
94
|
+
[terms](https://granttap.com/terms) and [Help / FAQ](https://granttap.com/support).
|
|
95
|
+
|
|
96
|
+
## Messages, queues and decisions
|
|
97
|
+
|
|
98
|
+
1. The controller encrypts the message or decision and submits it to the relay.
|
|
99
|
+
2. A **relay storage receipt** confirms that the relay saved it durably. The
|
|
100
|
+
computer can be asleep or offline at this point.
|
|
101
|
+
3. The computer receives the packet when connected, routes it to the intended
|
|
102
|
+
Task/execution and reports provider acceptance, failure or a required decision.
|
|
103
|
+
4. The provider's answer returns through the existing conversation.
|
|
104
|
+
|
|
105
|
+
A saved message does not mean the agent has started. Queue and delivery stages
|
|
106
|
+
remain distinct; bounded storage, retention, access checks and service limits
|
|
107
|
+
can reject a submission with an explicit error. A controller does not need to
|
|
108
|
+
wait for the execution computer before showing a confirmed relay save.
|
|
109
|
+
|
|
110
|
+
Use **Queue** to inspect waiting deliveries, **Send now** for an eligible queued
|
|
111
|
+
entry, and the available cancel action to stop a pending delivery. Delivery IDs
|
|
112
|
+
survive retries so a transport retry is not a request to run the prompt twice.
|
|
113
|
+
An already accepted or completed provider operation cannot be undone by deleting
|
|
114
|
+
its local queue card. Provider-wide deny rules always take precedence over a
|
|
115
|
+
mobile approval, including workspace and native-tool restrictions.
|
|
116
|
+
|
|
47
117
|
## Account Mesh and devices
|
|
48
118
|
|
|
49
119
|
The passkey Account Mesh is the person's relay space. It exists with zero
|
|
@@ -84,7 +154,7 @@ device pairing without a passkey account.
|
|
|
84
154
|
## Supported providers
|
|
85
155
|
|
|
86
156
|
- Claude Code and Codex: primary local control and continuation paths.
|
|
87
|
-
- Cursor: Task visibility and supported local controls, with narrower coverage.
|
|
157
|
+
- Cursor (Beta): Task visibility and supported local controls, with narrower coverage.
|
|
88
158
|
- Grok Build: observable work where the installed runtime exposes it.
|
|
89
159
|
|
|
90
160
|
GrantTap reports the depth each provider actually exposes. Visibility does not
|
|
@@ -542,8 +612,8 @@ is still plain `Shell`.
|
|
|
542
612
|
|
|
543
613
|
The Mesh keys a computer by an identity written down once, on first use, in
|
|
544
614
|
`computer.json` in the config directory — the hostname it had then — and
|
|
545
|
-
keeps it. A Mac renamed by the network it joins ("
|
|
546
|
-
"
|
|
615
|
+
keeps it. A Mac renamed by the network it joins ("workstation.home" on one network,
|
|
616
|
+
"workstation.local" elsewhere) used to become a second computer with
|
|
547
617
|
its own open executions and repository bindings; now every later hostname is
|
|
548
618
|
remembered as a former name of the same machine, its leftover executions are
|
|
549
619
|
closed and its bindings marked unavailable, and the current hostname stays
|
|
@@ -679,6 +749,38 @@ APNs carries a content-neutral wake only. It contains no prompt, task title,
|
|
|
679
749
|
command, path, request ID, or ciphertext. See [SECURITY.md](SECURITY.md) for the
|
|
680
750
|
complete boundary and reporting instructions.
|
|
681
751
|
|
|
752
|
+
## Troubleshooting and help
|
|
753
|
+
|
|
754
|
+
- **Computer not visible:** run `granttap status`, check the helper is running,
|
|
755
|
+
then check Devices in the controller. The same passkey links the same account;
|
|
756
|
+
a display name or IP address is not a computer identity.
|
|
757
|
+
- **Mac asks for local access:** complete GrantTap's local consent flow for the
|
|
758
|
+
installed helper. A provider OAuth login and granting the Mac app local access
|
|
759
|
+
serve different purposes.
|
|
760
|
+
- **Message saved while the computer is offline:** keep the queued entry. A
|
|
761
|
+
relay receipt confirms storage, while the agent can continue only when its
|
|
762
|
+
execution computer and provider become available.
|
|
763
|
+
- **An approval cannot be completed:** open the exact Task and inspect its native
|
|
764
|
+
restriction or pending form. GrantTap cannot override a provider's global deny.
|
|
765
|
+
- **History or Agents missing:** check that the execution is routed to the right
|
|
766
|
+
computer and update both the helper and controller. Nested agents stay inside
|
|
767
|
+
their parent Task; they are not separate root Tasks.
|
|
768
|
+
- **Health has no current architecture:** check the reported checkout and Engine
|
|
769
|
+
on the publishing computer, then request a rebuild. An old or partial report
|
|
770
|
+
remains inspectable and must not be treated as verified current architecture.
|
|
771
|
+
- **No purchase shown:** the product may not be released in that storefront.
|
|
772
|
+
TestFlight does not require a purchase. Use Restore Purchases for an existing
|
|
773
|
+
Apple purchase and the same GrantTap passkey for account access.
|
|
774
|
+
|
|
775
|
+
Update the helper with `npm install -g granttap-mcp@latest`, then reopen the
|
|
776
|
+
coding-provider integration if it still runs an older process. Keep existing
|
|
777
|
+
pairings; resetting an account or removing local history is not an update step.
|
|
778
|
+
|
|
779
|
+
[Help and connection pictures](https://granttap.com/support) are also available
|
|
780
|
+
in the Apple apps. Use the support contact there for product issues. Report
|
|
781
|
+
security vulnerabilities through [SECURITY.md](SECURITY.md); do not put pairing
|
|
782
|
+
QR codes, credentials or private chat transcripts into public issues.
|
|
783
|
+
|
|
682
784
|
## Development
|
|
683
785
|
|
|
684
786
|
```bash
|
package/cursor-plugin/README.md
CHANGED
|
@@ -10,7 +10,7 @@ keeps each phone response tied to the exact originating chat and prompt.
|
|
|
10
10
|
Install the supported CLI on this computer, then run:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
npm install -g granttap-mcp@0.8.
|
|
13
|
+
npm install -g granttap-mcp@0.8.81
|
|
14
14
|
granttap setup
|
|
15
15
|
granttap status
|
|
16
16
|
```
|
package/cursor-plugin/mcp.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"command": "node",
|
|
5
5
|
"args": [
|
|
6
6
|
"-e",
|
|
7
|
-
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.
|
|
7
|
+
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.81\";\nconst dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === \"1\";\nconst platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM\n ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;\nconst nodeRoot = dryRun && process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT\n ? process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT : dirname(process.execPath);\nconst pathRoots = (process.env.PATH || \"\").split(delimiter).filter(Boolean);\nconst appData = process.env.APPDATA;\nconst roots = [nodeRoot, ...pathRoots, ...(appData ? [join(appData, \"npm\")] : [])];\nconst first = (paths) => paths.find((path) => existsSync(path));\n\nfunction launch() {\n if (platform !== \"win32\") {\n const installed = first(roots.map((root) => join(root, \"granttap-mcp\")));\n if (installed) return { command: installed, args: [] };\n const npx = first(roots.map((root) => join(root, \"npx\"))) || \"npx\";\n return { command: npx, args: [\"-y\", PACKAGE] };\n }\n const installed = first(roots.map((root) =>\n join(root, \"node_modules\", \"granttap-mcp\", \"bin\", \"granttap-mcp.mjs\")));\n if (installed) return { command: process.execPath, args: [installed] };\n const npx = first(roots.map((root) =>\n join(root, \"node_modules\", \"npm\", \"bin\", \"npx-cli.js\")));\n if (npx) return { command: process.execPath, args: [npx, \"-y\", PACKAGE] };\n throw new Error(\"Node.js/npm 20+ is required on this Windows device. Install npm and retry GrantTap.\");\n}\n\nlet target;\ntry { target = launch(); }\ncatch (error) {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n}\n\nif (dryRun) {\n process.stdout.write(JSON.stringify(target));\n process.exit(0);\n}\n\nconst child = spawn(target.command, target.args, { stdio: \"inherit\", windowsHide: true });\nchild.on(\"error\", (error) => {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n});\nchild.on(\"exit\", (code, signal) => {\n if (signal && platform !== \"win32\") {\n try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }\n return;\n }\n process.exit(code ?? 1);\n});\n"
|
|
8
8
|
]
|
|
9
9
|
}
|
|
10
10
|
}
|
|
@@ -7,7 +7,7 @@ const { spawn } = require("node:child_process");
|
|
|
7
7
|
const { existsSync } = require("node:fs");
|
|
8
8
|
const { delimiter, dirname, join } = require("node:path");
|
|
9
9
|
|
|
10
|
-
const PACKAGE = "granttap-mcp@0.8.
|
|
10
|
+
const PACKAGE = "granttap-mcp@0.8.81";
|
|
11
11
|
const dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === "1";
|
|
12
12
|
const platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM
|
|
13
13
|
? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;
|
package/package.json
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
/** Shared authenticated relay transport. See README.md for delivery and encryption boundaries. */
|
|
2
2
|
import WebSocket from "ws";
|
|
3
|
-
import { ManagedRelayLease, RelaySocketHealth, relayUpgradeHeaders } from "./socket-lifecycle";
|
|
3
|
+
import { ManagedRelayLease, RelaySocketHealth, RelayReconnectBackoff, relayUpgradeHeaders } from "./socket-lifecycle";
|
|
4
4
|
import { OutboundFrameQueue, sendEncryptedPayload } from "./outbound-scheduler";
|
|
5
5
|
import { openRelayMessage } from "./relay-inbound";
|
|
6
6
|
import { consumeTaskJobOffer, type TaskDispatchListener } from "./task-dispatch";
|
|
7
7
|
import { Payload, type Role } from "../protocol/schema";
|
|
8
|
-
import { peerPublicKeys
|
|
8
|
+
import { peerPublicKeys } from "./crypto";
|
|
9
9
|
import type { PeerConfig, RelayClientOptions, SendOptions } from "./relay-client-types";
|
|
10
10
|
export type { PeerConfig, RelayClientOptions, SendOptions } from "./relay-client-types";
|
|
11
11
|
import { RelayMessageAcceptance } from "./replay-store";
|
|
12
|
-
import { sealEnvelope } from "./relay-envelope";
|
|
12
|
+
import { sealEnvelope, sealSessionPayload } from "./relay-envelope";
|
|
13
13
|
export { sealEnvelope } from "./relay-envelope";
|
|
14
|
-
|
|
15
14
|
/** Return true only after this consumer has durably accepted the payload. */
|
|
16
15
|
type Listener = (p: Payload, peerPublicKey: string) => boolean | void | Promise<boolean | void>;
|
|
17
16
|
|
|
@@ -25,7 +24,7 @@ export class RelayClient {
|
|
|
25
24
|
private intentionalClose = false;
|
|
26
25
|
private connectPromise?: Promise<void>;
|
|
27
26
|
private reconnectTimer?: NodeJS.Timeout;
|
|
28
|
-
private
|
|
27
|
+
private readonly reconnect: RelayReconnectBackoff;
|
|
29
28
|
private readonly socketHealth = new RelaySocketHealth();
|
|
30
29
|
private readonly admissionLease = new ManagedRelayLease();
|
|
31
30
|
private readonly sessionKeys = new Map<string, string>();
|
|
@@ -35,7 +34,7 @@ export class RelayClient {
|
|
|
35
34
|
private readonly cfg: PeerConfig,
|
|
36
35
|
private readonly opts: RelayClientOptions = {},
|
|
37
36
|
) {
|
|
38
|
-
this.
|
|
37
|
+
this.reconnect = new RelayReconnectBackoff(opts.minReconnectMs, opts.maxReconnectMs);
|
|
39
38
|
if (opts.onTaskDispatch) this.taskDispatchListeners.add(opts.onTaskDispatch);
|
|
40
39
|
this.replay = new RelayMessageAcceptance(opts.replayPath);
|
|
41
40
|
}
|
|
@@ -57,6 +56,7 @@ export class RelayClient {
|
|
|
57
56
|
connect(timeoutMs = 10_000): Promise<void> {
|
|
58
57
|
if (this.isConnected) return Promise.resolve();
|
|
59
58
|
if (this.connectPromise) return this.connectPromise;
|
|
59
|
+
if (this.reconnectTimer) return Promise.reject(new Error('Relay reconnect is waiting for its retry window.'));
|
|
60
60
|
|
|
61
61
|
this.intentionalClose = false;
|
|
62
62
|
this.recipientRouting = false;
|
|
@@ -76,7 +76,7 @@ export class RelayClient {
|
|
|
76
76
|
ws.send(frame, error => error ? reject(error) : resolve());
|
|
77
77
|
}));
|
|
78
78
|
this.frames = frames;
|
|
79
|
-
let opened = false;
|
|
79
|
+
let opened = false, openedAt: number | undefined;
|
|
80
80
|
const timer = setTimeout(() => {
|
|
81
81
|
ws.terminate();
|
|
82
82
|
reject(new Error(`relay connect timeout (${this.cfg.relayUrl})`));
|
|
@@ -85,7 +85,7 @@ export class RelayClient {
|
|
|
85
85
|
ws.on("open", () => {
|
|
86
86
|
opened = true;
|
|
87
87
|
clearTimeout(timer);
|
|
88
|
-
|
|
88
|
+
openedAt = Date.now();
|
|
89
89
|
this.socketHealth.start(ws, this.opts.pingIntervalMs ?? 20_000);
|
|
90
90
|
this.admissionLease.start(ws, this.opts);
|
|
91
91
|
// Announce ourselves. The envelope's cleartext (room, from) is what the
|
|
@@ -99,16 +99,21 @@ export class RelayClient {
|
|
|
99
99
|
resolve();
|
|
100
100
|
});
|
|
101
101
|
ws.on("message", (data: WebSocket.RawData) => void this.onRaw(data.toString()));
|
|
102
|
+
ws.on('unexpected-response', (_request, response) => {
|
|
103
|
+
this.reconnect.defer(response.headers['retry-after']);
|
|
104
|
+
response.resume(); ws.terminate();
|
|
105
|
+
clearTimeout(timer); reject(new Error('Relay upgrade was not accepted.'));
|
|
106
|
+
});
|
|
102
107
|
ws.on("error", (err) => {
|
|
103
108
|
clearTimeout(timer);
|
|
104
109
|
if (!opened) reject(err);
|
|
105
110
|
});
|
|
106
|
-
ws.on("close", () => {
|
|
111
|
+
ws.on("close", (code, reason) => {
|
|
107
112
|
frames.cancel();
|
|
108
113
|
clearTimeout(timer);
|
|
109
114
|
this.socketHealth.stop(ws);
|
|
110
115
|
this.admissionLease.stop(ws);
|
|
111
|
-
if (this.ws === ws) this.ws = undefined;
|
|
116
|
+
if (this.ws === ws) { this.reconnect.closed(openedAt, code, reason.toString()); this.ws = undefined; }
|
|
112
117
|
if (!opened) reject(new Error(`relay connection closed (${this.cfg.relayUrl})`));
|
|
113
118
|
this.scheduleReconnect();
|
|
114
119
|
});
|
|
@@ -219,16 +224,7 @@ export class RelayClient {
|
|
|
219
224
|
to: Role | "all" = this.otherRole(),
|
|
220
225
|
options: SendOptions = {},
|
|
221
226
|
): Promise<void> {
|
|
222
|
-
|
|
223
|
-
if (!key) throw new Error(`no encryption key for task ${sessionId}`);
|
|
224
|
-
const sealed = sealWithTransferKey(payload, key);
|
|
225
|
-
await this.send({
|
|
226
|
-
type: "session.sealed",
|
|
227
|
-
sessionId,
|
|
228
|
-
nonce: sealed.nonce,
|
|
229
|
-
box: sealed.box,
|
|
230
|
-
createdAt: Date.now(),
|
|
231
|
-
}, to, options);
|
|
227
|
+
await this.send(sealSessionPayload(payload, sessionId, this.sessionKeys.get(sessionId)), to, options);
|
|
232
228
|
}
|
|
233
229
|
|
|
234
230
|
/** Register before connect so only durable receivers advertise task-job support. */
|
|
@@ -284,9 +280,7 @@ export class RelayClient {
|
|
|
284
280
|
|
|
285
281
|
private scheduleReconnect(): void {
|
|
286
282
|
if (!this.opts.autoReconnect || this.intentionalClose || this.reconnectTimer) return;
|
|
287
|
-
const delay = this.
|
|
288
|
-
const max = this.opts.maxReconnectMs ?? 15_000;
|
|
289
|
-
this.reconnectDelay = Math.min(max, delay * 2);
|
|
283
|
+
const delay = this.reconnect.delay();
|
|
290
284
|
this.reconnectTimer = setTimeout(() => {
|
|
291
285
|
this.reconnectTimer = undefined;
|
|
292
286
|
void this.connect().catch(() => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { Envelope, PROTOCOL_VERSION, type Payload, type Role } from "../protocol/schema";
|
|
3
3
|
import { publicKeyIdentifier } from "./ciphertext-recipient";
|
|
4
|
-
import { seal } from "./crypto";
|
|
4
|
+
import { seal, sealWithTransferKey } from "./crypto";
|
|
5
5
|
import type { PeerConfig, SendOptions } from "./relay-client-types";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -37,3 +37,10 @@ export function sealEnvelope(
|
|
|
37
37
|
};
|
|
38
38
|
return JSON.stringify(envelope);
|
|
39
39
|
}
|
|
40
|
+
|
|
41
|
+
/** Apply the independent task encryption layer before transport encryption. */
|
|
42
|
+
export function sealSessionPayload(payload: Payload, sessionId: string, key: string | undefined): Payload {
|
|
43
|
+
if (!key) throw new Error(`no encryption key for task ${sessionId}`);
|
|
44
|
+
const { nonce, box } = sealWithTransferKey(payload, key);
|
|
45
|
+
return { type: 'session.sealed', sessionId, nonce, box, createdAt: Date.now() };
|
|
46
|
+
}
|
|
@@ -6,6 +6,15 @@ socket and does not rotate task keys or reload transcript state. A failed lookup
|
|
|
6
6
|
cannot extend the server's signed expiry. Closing or replacing a socket cancels
|
|
7
7
|
only its own timers.
|
|
8
8
|
|
|
9
|
+
Reconnect pacing resets only after a non-policy connection lived at least 30
|
|
10
|
+
seconds. Short handshakes keep exponential backoff and jitter. WebSocket
|
|
11
|
+
`retry-after` reasons and HTTP `Retry-After` are lower bounds even when they
|
|
12
|
+
exceed the ordinary retry cap. Foreground polling cannot bypass a scheduled
|
|
13
|
+
retry. None of these waits delay a submission already persisted on the relay.
|
|
14
|
+
Every endpoint advertises its public-key digest before the upgrade, including
|
|
15
|
+
transient machine hooks, so a server can refuse a cooled identity before it
|
|
16
|
+
receives any ciphertext. This digest grants no permissions or decryption rights.
|
|
17
|
+
|
|
9
18
|
The real WebSocket integration is in `tests/core/managed-relay-renewal.test.ts`.
|
|
10
19
|
|
|
11
20
|
Licensed under the GrantTap Commercial Source License in the repository root.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Retry pacing survives refused handshakes and short-lived WebSockets. */
|
|
2
|
+
export class RelayReconnectBackoff {
|
|
3
|
+
private next: number;
|
|
4
|
+
private notBefore = 0;
|
|
5
|
+
|
|
6
|
+
constructor(private readonly minimum = 1_000, private readonly maximum = 15_000,
|
|
7
|
+
private readonly now = Date.now, private readonly random = Math.random) {
|
|
8
|
+
this.next = minimum;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
closed(openedAt: number | undefined, code: number, reason: string): void {
|
|
12
|
+
const policy = code === 1008 || code === 1009 || (code >= 4400 && code <= 4499);
|
|
13
|
+
if (!policy && openedAt !== undefined && this.now() - openedAt >= 30_000) this.next = this.minimum;
|
|
14
|
+
this.defer(/(?:^|;\s*)retry-after=(\d{1,5})(?:;|$)/.exec(reason)?.[1]);
|
|
15
|
+
if (policy) this.notBefore = Math.max(this.notBefore, this.now() + Math.min(60_000, this.maximum));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Server Retry-After is a floor, independent of the ordinary reconnect cap. */
|
|
19
|
+
defer(value: string | string[] | undefined): void {
|
|
20
|
+
if (typeof value !== 'string' || !/^\d{1,5}$/.test(value)) return;
|
|
21
|
+
this.notBefore = Math.max(this.notBefore, this.now() + Number(value) * 1_000);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
delay(): number {
|
|
25
|
+
const jittered = Math.round(Math.max(this.minimum, Math.min(this.maximum, this.next * (0.8 + this.random() * 0.4))));
|
|
26
|
+
this.next = Math.min(this.maximum, this.next * 2);
|
|
27
|
+
return Math.max(jittered, this.notBefore - this.now());
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -4,8 +4,7 @@ import { publicKeyIdentifier } from '../ciphertext-recipient';
|
|
|
4
4
|
/** Bind optional mailbox routing to this endpoint's real encryption identity. */
|
|
5
5
|
export function relayUpgradeHeaders(cfg: PeerConfig, admission: Record<string, string>, durableReceiver: boolean) {
|
|
6
6
|
return { ...admission, 'X-GrantTap-Recipient-Routing': '1',
|
|
7
|
-
|
|
8
|
-
? { 'X-GrantTap-Recipient-Key-Id': publicKeyIdentifier(cfg.myPublicKey) } : {}),
|
|
7
|
+
'X-GrantTap-Recipient-Key-Id': publicKeyIdentifier(cfg.myPublicKey),
|
|
9
8
|
...(durableReceiver ? { 'X-GrantTap-Task-Dispatch': '1' } : {}),
|
|
10
9
|
...(cfg.pushAuth ? { Authorization: `Bearer ${cfg.pushAuth}` } : {}) };
|
|
11
10
|
}
|