visionclaw 0.1.185-beta.8 → 0.1.185-beta.9

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.
Files changed (35) hide show
  1. package/dist/agent/applied-credential-signature.d.ts +53 -0
  2. package/dist/agent/applied-credential-signature.d.ts.map +1 -0
  3. package/dist/agent/applied-credential-signature.js +137 -0
  4. package/dist/agent/applied-credential-signature.js.map +1 -0
  5. package/dist/agent/credential-bundle-handler.d.ts +98 -0
  6. package/dist/agent/credential-bundle-handler.d.ts.map +1 -0
  7. package/dist/agent/credential-bundle-handler.js +161 -0
  8. package/dist/agent/credential-bundle-handler.js.map +1 -0
  9. package/dist/agent/heartbeat-manager.d.ts +20 -14
  10. package/dist/agent/heartbeat-manager.d.ts.map +1 -1
  11. package/dist/agent/heartbeat-manager.js +28 -52
  12. package/dist/agent/heartbeat-manager.js.map +1 -1
  13. package/dist/agent/loop.d.ts.map +1 -1
  14. package/dist/agent/loop.js +7 -1
  15. package/dist/agent/loop.js.map +1 -1
  16. package/dist/agent/runtime-credentials.d.ts +21 -7
  17. package/dist/agent/runtime-credentials.d.ts.map +1 -1
  18. package/dist/agent/runtime-credentials.js +131 -28
  19. package/dist/agent/runtime-credentials.js.map +1 -1
  20. package/dist/agent/tunnel-credential-handler.d.ts +90 -0
  21. package/dist/agent/tunnel-credential-handler.d.ts.map +1 -0
  22. package/dist/agent/tunnel-credential-handler.js +162 -0
  23. package/dist/agent/tunnel-credential-handler.js.map +1 -0
  24. package/dist/obs/legacy-tunnel-adoption.d.ts +1 -1
  25. package/dist/obs/legacy-tunnel-adoption.js +1 -1
  26. package/dist/obs/server.d.ts.map +1 -1
  27. package/dist/obs/server.js +18 -1
  28. package/dist/obs/server.js.map +1 -1
  29. package/dist/obs/tunnel.d.ts +1 -1
  30. package/dist/onboarding/index.js +1 -1
  31. package/dist/onboarding/onboarding-service-client.d.ts +32 -12
  32. package/dist/onboarding/onboarding-service-client.d.ts.map +1 -1
  33. package/dist/onboarding/onboarding-service-client.js.map +1 -1
  34. package/dist-agent/bundle.cjs +208 -63
  35. package/package.json +1 -1
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Applied-credential signature manifest.
3
+ *
4
+ * Persisted alongside materialised tunnel credentials at
5
+ * `<profileTunnelDir>/.applied-signature.json`. Records the signature
6
+ * the server handed us with the last bundle we successfully applied.
7
+ *
8
+ * The manifest is the *only* piece of state the agent has to remember
9
+ * across process restarts about credential bundles — everything else
10
+ * (creds JSON, config.yml) is reconstructable from a fresh server
11
+ * delivery. The signature is opaque to us; we just echo it back on the
12
+ * next heartbeat so the server can decide whether to re-ship the body.
13
+ *
14
+ * Self-healing properties (intentional):
15
+ * - Manifest missing on disk → no signature sent → server sends
16
+ * bundle → we re-apply and rewrite the manifest. Recovers any
17
+ * install where the manifest was deleted but credentials weren't,
18
+ * or vice versa.
19
+ * - Manifest present but probe says no live tunnel files → we treat
20
+ * the manifest as unusable (return null from `loadIfValid`), the
21
+ * next heartbeat goes signature-free, the server re-ships the
22
+ * bundle, and we land back in a coherent state.
23
+ * - Process restart with everything intact → manifest signature is
24
+ * sent on the startup heartbeat, server replies with no
25
+ * `credentialBundle`, no disk writes, no tunnel restart.
26
+ */
27
+ /**
28
+ * Read the persisted signature, if any. Returns `null` when the file
29
+ * does not exist, fails to parse, has the wrong shape, or refers to a
30
+ * tunnel state that no longer matches what's on disk.
31
+ *
32
+ * The on-disk-coherence check (`probeLocalTunnelCredential()`) is what
33
+ * gives us self-healing: a stranded manifest from a wiped tunnel dir
34
+ * gets ignored, forcing a fresh bundle on the next heartbeat.
35
+ */
36
+ export declare function loadAppliedSignatureIfValid(): string | null;
37
+ /**
38
+ * Persist the signature for the most recently applied bundle. Best-
39
+ * effort: a write failure is logged but does not throw, since the
40
+ * worst case is just an extra full-bundle delivery on the next
41
+ * heartbeat.
42
+ *
43
+ * The file is written with 0o600 to match the credentials sitting next
44
+ * to it; the directory itself is already 0o700 from
45
+ * `runtime-credentials.ts`.
46
+ */
47
+ export declare function writeAppliedSignature(signature: string): void;
48
+ /**
49
+ * Forget the persisted signature. Used by tests; production code never
50
+ * needs to delete it explicitly because writes overwrite in place.
51
+ */
52
+ export declare function __clearAppliedSignatureForTests(): void;
53
+ //# sourceMappingURL=applied-credential-signature.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"applied-credential-signature.d.ts","sourceRoot":"","sources":["../../src/agent/applied-credential-signature.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAwBH;;;;;;;;GAQG;AACH,wBAAgB,2BAA2B,IAAI,MAAM,GAAG,IAAI,CA2C3D;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAgC7D;AAED;;;GAGG;AACH,wBAAgB,+BAA+B,IAAI,IAAI,CAMtD"}
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Applied-credential signature manifest.
3
+ *
4
+ * Persisted alongside materialised tunnel credentials at
5
+ * `<profileTunnelDir>/.applied-signature.json`. Records the signature
6
+ * the server handed us with the last bundle we successfully applied.
7
+ *
8
+ * The manifest is the *only* piece of state the agent has to remember
9
+ * across process restarts about credential bundles — everything else
10
+ * (creds JSON, config.yml) is reconstructable from a fresh server
11
+ * delivery. The signature is opaque to us; we just echo it back on the
12
+ * next heartbeat so the server can decide whether to re-ship the body.
13
+ *
14
+ * Self-healing properties (intentional):
15
+ * - Manifest missing on disk → no signature sent → server sends
16
+ * bundle → we re-apply and rewrite the manifest. Recovers any
17
+ * install where the manifest was deleted but credentials weren't,
18
+ * or vice versa.
19
+ * - Manifest present but probe says no live tunnel files → we treat
20
+ * the manifest as unusable (return null from `loadIfValid`), the
21
+ * next heartbeat goes signature-free, the server re-ships the
22
+ * bundle, and we land back in a coherent state.
23
+ * - Process restart with everything intact → manifest signature is
24
+ * sent on the startup heartbeat, server replies with no
25
+ * `credentialBundle`, no disk writes, no tunnel restart.
26
+ */
27
+ import fs from "node:fs";
28
+ import path from "node:path";
29
+ import { getProfileTunnelDir } from "../config/index.js";
30
+ import { probeLocalTunnelCredential } from "./runtime-credentials.js";
31
+ import { logger } from "../logger.js";
32
+ const MANIFEST_FILENAME = ".applied-signature.json";
33
+ const MANIFEST_VERSION = 1;
34
+ function getManifestPath() {
35
+ return path.join(getProfileTunnelDir(), MANIFEST_FILENAME);
36
+ }
37
+ /**
38
+ * Read the persisted signature, if any. Returns `null` when the file
39
+ * does not exist, fails to parse, has the wrong shape, or refers to a
40
+ * tunnel state that no longer matches what's on disk.
41
+ *
42
+ * The on-disk-coherence check (`probeLocalTunnelCredential()`) is what
43
+ * gives us self-healing: a stranded manifest from a wiped tunnel dir
44
+ * gets ignored, forcing a fresh bundle on the next heartbeat.
45
+ */
46
+ export function loadAppliedSignatureIfValid() {
47
+ const manifestPath = getManifestPath();
48
+ if (!fs.existsSync(manifestPath))
49
+ return null;
50
+ let raw;
51
+ try {
52
+ raw = fs.readFileSync(manifestPath, "utf-8");
53
+ }
54
+ catch (err) {
55
+ logger.warn(`Applied-signature manifest unreadable: ${err instanceof Error ? err.message : String(err)}`);
56
+ return null;
57
+ }
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(raw);
61
+ }
62
+ catch {
63
+ logger.warn(`Applied-signature manifest malformed; ignoring`);
64
+ return null;
65
+ }
66
+ if (!parsed ||
67
+ typeof parsed !== "object" ||
68
+ !("signature" in parsed) ||
69
+ typeof parsed.signature !== "string" ||
70
+ parsed.signature.trim() === "") {
71
+ return null;
72
+ }
73
+ const signature = parsed.signature.trim();
74
+ // Coherence: a manifest is only meaningful when the credentials it
75
+ // refers to actually still exist on disk. If the tunnel files were
76
+ // wiped (e.g. operator deleted `~/.visionclaw/profiles/default/tunnel`)
77
+ // the stored signature lies about our state, so pretend we have none
78
+ // and let the server reissue the bundle.
79
+ if (probeLocalTunnelCredential() === null) {
80
+ return null;
81
+ }
82
+ return signature;
83
+ }
84
+ /**
85
+ * Persist the signature for the most recently applied bundle. Best-
86
+ * effort: a write failure is logged but does not throw, since the
87
+ * worst case is just an extra full-bundle delivery on the next
88
+ * heartbeat.
89
+ *
90
+ * The file is written with 0o600 to match the credentials sitting next
91
+ * to it; the directory itself is already 0o700 from
92
+ * `runtime-credentials.ts`.
93
+ */
94
+ export function writeAppliedSignature(signature) {
95
+ if (typeof signature !== "string" || signature.trim() === "")
96
+ return;
97
+ const manifestPath = getManifestPath();
98
+ const dir = path.dirname(manifestPath);
99
+ try {
100
+ fs.mkdirSync(dir, { recursive: true });
101
+ }
102
+ catch {
103
+ // best-effort; the credential apply path will have created it
104
+ }
105
+ const payload = {
106
+ version: MANIFEST_VERSION,
107
+ signature: signature.trim(),
108
+ appliedAt: new Date().toISOString(),
109
+ };
110
+ try {
111
+ fs.writeFileSync(manifestPath, JSON.stringify(payload, null, 2), "utf-8");
112
+ if (process.platform !== "win32") {
113
+ try {
114
+ fs.chmodSync(manifestPath, 0o600);
115
+ }
116
+ catch {
117
+ // best-effort; directory perms still protect
118
+ }
119
+ }
120
+ }
121
+ catch (err) {
122
+ logger.warn(`Failed to persist applied-signature manifest: ${err instanceof Error ? err.message : String(err)}`);
123
+ }
124
+ }
125
+ /**
126
+ * Forget the persisted signature. Used by tests; production code never
127
+ * needs to delete it explicitly because writes overwrite in place.
128
+ */
129
+ export function __clearAppliedSignatureForTests() {
130
+ try {
131
+ fs.rmSync(getManifestPath(), { force: true });
132
+ }
133
+ catch {
134
+ // ignore
135
+ }
136
+ }
137
+ //# sourceMappingURL=applied-credential-signature.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"applied-credential-signature.js","sourceRoot":"","sources":["../../src/agent/applied-credential-signature.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Credential bundle coordinator.
3
+ *
4
+ * Owns every concern around server-issued `credentialBundle` deliveries
5
+ * over the heartbeat:
6
+ *
7
+ * - exposing the locally-applied signature so the heartbeat layer can
8
+ * send it back to the server (which decides whether to re-ship)
9
+ * - materialising delivered bundles to disk (via `runtime-credentials.ts`)
10
+ * - bouncing cloudflared when a tunnel rotation lands, with restart
11
+ * coalescing so concurrent rotations don't race
12
+ *
13
+ * Replaces the old `TunnelCredentialHandler` + `bootstrapCredentials`
14
+ * flag heuristic. The signature path is server-authoritative and
15
+ * forward-compatible: when a future credential kind is added to
16
+ * `CredentialBundleBody`, the same signature mechanism naturally
17
+ * detects + delivers it without any new wire fields.
18
+ *
19
+ * Persistence model — deliberately none. The applied signature lives
20
+ * only in memory, which means **every fresh process gets a full bundle
21
+ * delivery on its first heartbeat**. That's the recovery path for any
22
+ * disk drift we can't enumerate in advance: a corrupted creds JSON, a
23
+ * stale config.yml, a wiped profile dir, an operator-edited manifest —
24
+ * doesn't matter. Restart the agent, the server hands us the current
25
+ * bundle, we re-apply, we're back in sync. The cost is one bundle
26
+ * download per process lifetime (a few KB), which is negligible.
27
+ */
28
+ import type { CredentialBundle } from "../onboarding/onboarding-service-client.js";
29
+ export interface CredentialBundleHandlerOptions {
30
+ /** Canonical agent name used to validate tunnel-name drift. */
31
+ agentName: string;
32
+ /**
33
+ * Callback that bounces cloudflared (typically the `restartTunnel`
34
+ * returned from `startObsServer`). Invoked when a newly materialised
35
+ * tunnel credential differs from what's on disk — otherwise
36
+ * cloudflared keeps running against the UUID it was spawned with
37
+ * (it does not hot-reload `config.yml`).
38
+ *
39
+ * Optional so test harnesses and non-obs builds can skip the restart
40
+ * side-effect entirely.
41
+ */
42
+ restartTunnel?: () => Promise<string | null>;
43
+ }
44
+ export declare class CredentialBundleHandler {
45
+ private readonly agentName;
46
+ private readonly restartTunnel?;
47
+ /**
48
+ * The signature the agent will send to the server on the next
49
+ * heartbeat. `undefined` means "I have no opinion, please send me
50
+ * the bundle" — which is the state every fresh process starts in,
51
+ * by design. Updated only after a successful apply.
52
+ */
53
+ private _appliedSignature;
54
+ private _restartInFlight;
55
+ /**
56
+ * Set whenever a `tunnelChanged` apply lands while a previous restart
57
+ * is still in flight. The in-flight restart already snapshotted disk
58
+ * state at its start (`resolveTunnelOpts()` in `obs/server.ts`), so
59
+ * the second rotation needs its own bounce — we re-kick from the
60
+ * restart's `finally` block when this flag is set.
61
+ *
62
+ * Stored as the latest `ApplyRuntimeCredentialsResult` so the
63
+ * rotation label in the follow-up log line still reflects the
64
+ * *latest* tunnel delivery, not the first one already restarted.
65
+ */
66
+ private _pendingRestart;
67
+ constructor(options: CredentialBundleHandlerOptions);
68
+ /**
69
+ * The signature to attach to the next heartbeat as
70
+ * `appliedCredentialSignature`. `undefined` until the first
71
+ * successful apply in this process — which forces the server to
72
+ * ship the bundle on the startup heartbeat (the recovery path).
73
+ */
74
+ get appliedSignature(): string | undefined;
75
+ /**
76
+ * Handle the optional `credentialBundle` from a heartbeat response.
77
+ *
78
+ * Defensive by contract: never throws — a failed materialisation must
79
+ * not block heartbeats. A missing/undefined bundle means "server
80
+ * agrees with our applied signature": no work to do.
81
+ */
82
+ handle(bundle: CredentialBundle | undefined): void;
83
+ /**
84
+ * Kick `restartTunnel` in the background, coalescing concurrent
85
+ * invocations. Logged at `system` level because a restart is a
86
+ * visible event for operators tailing pm2 logs.
87
+ *
88
+ * If a restart is already in flight when we're called, we don't drop
89
+ * the trigger — the in-flight restart sampled disk before this newer
90
+ * credential was materialised and would launch cloudflared against
91
+ * the stale UUID. Instead we record the latest result in
92
+ * `_pendingRestart` and re-kick from the in-flight restart's
93
+ * `finally` block, which guarantees one extra restart that picks up
94
+ * whatever's now on disk.
95
+ */
96
+ private triggerRestart;
97
+ }
98
+ //# sourceMappingURL=credential-bundle-handler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credential-bundle-handler.d.ts","sourceRoot":"","sources":["../../src/agent/credential-bundle-handler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EACV,gBAAgB,EACjB,MAAM,4CAA4C,CAAC;AAOpD,MAAM,WAAW,8BAA8B;IAC7C,+DAA+D;IAC/D,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;;;OASG;IACH,aAAa,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CAC9C;AAED,qBAAa,uBAAuB;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC,CAA+B;IAE9D;;;;;OAKG;IACH,OAAO,CAAC,iBAAiB,CAAiC;IAE1D,OAAO,CAAC,gBAAgB,CAA8B;IACtD;;;;;;;;;;OAUG;IACH,OAAO,CAAC,eAAe,CAA8C;gBAEzD,OAAO,EAAE,8BAA8B;IAKnD;;;;;OAKG;IACH,IAAI,gBAAgB,IAAI,MAAM,GAAG,SAAS,CAEzC;IAED;;;;;;OAMG;IACH,MAAM,CAAC,MAAM,EAAE,gBAAgB,GAAG,SAAS,GAAG,IAAI;IAuClD;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,cAAc;CA6CvB"}
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Credential bundle coordinator.
3
+ *
4
+ * Owns every concern around server-issued `credentialBundle` deliveries
5
+ * over the heartbeat:
6
+ *
7
+ * - exposing the locally-applied signature so the heartbeat layer can
8
+ * send it back to the server (which decides whether to re-ship)
9
+ * - materialising delivered bundles to disk (via `runtime-credentials.ts`)
10
+ * - bouncing cloudflared when a tunnel rotation lands, with restart
11
+ * coalescing so concurrent rotations don't race
12
+ *
13
+ * Replaces the old `TunnelCredentialHandler` + `bootstrapCredentials`
14
+ * flag heuristic. The signature path is server-authoritative and
15
+ * forward-compatible: when a future credential kind is added to
16
+ * `CredentialBundleBody`, the same signature mechanism naturally
17
+ * detects + delivers it without any new wire fields.
18
+ *
19
+ * Persistence model — deliberately none. The applied signature lives
20
+ * only in memory, which means **every fresh process gets a full bundle
21
+ * delivery on its first heartbeat**. That's the recovery path for any
22
+ * disk drift we can't enumerate in advance: a corrupted creds JSON, a
23
+ * stale config.yml, a wiped profile dir, an operator-edited manifest —
24
+ * doesn't matter. Restart the agent, the server hands us the current
25
+ * bundle, we re-apply, we're back in sync. The cost is one bundle
26
+ * download per process lifetime (a few KB), which is negligible.
27
+ */
28
+ import { applyRuntimeCredentials, } from "./runtime-credentials.js";
29
+ import { logger } from "../logger.js";
30
+ export class CredentialBundleHandler {
31
+ agentName;
32
+ restartTunnel;
33
+ /**
34
+ * The signature the agent will send to the server on the next
35
+ * heartbeat. `undefined` means "I have no opinion, please send me
36
+ * the bundle" — which is the state every fresh process starts in,
37
+ * by design. Updated only after a successful apply.
38
+ */
39
+ _appliedSignature = undefined;
40
+ _restartInFlight = null;
41
+ /**
42
+ * Set whenever a `tunnelChanged` apply lands while a previous restart
43
+ * is still in flight. The in-flight restart already snapshotted disk
44
+ * state at its start (`resolveTunnelOpts()` in `obs/server.ts`), so
45
+ * the second rotation needs its own bounce — we re-kick from the
46
+ * restart's `finally` block when this flag is set.
47
+ *
48
+ * Stored as the latest `ApplyRuntimeCredentialsResult` so the
49
+ * rotation label in the follow-up log line still reflects the
50
+ * *latest* tunnel delivery, not the first one already restarted.
51
+ */
52
+ _pendingRestart = null;
53
+ constructor(options) {
54
+ this.agentName = options.agentName;
55
+ this.restartTunnel = options.restartTunnel;
56
+ }
57
+ /**
58
+ * The signature to attach to the next heartbeat as
59
+ * `appliedCredentialSignature`. `undefined` until the first
60
+ * successful apply in this process — which forces the server to
61
+ * ship the bundle on the startup heartbeat (the recovery path).
62
+ */
63
+ get appliedSignature() {
64
+ return this._appliedSignature;
65
+ }
66
+ /**
67
+ * Handle the optional `credentialBundle` from a heartbeat response.
68
+ *
69
+ * Defensive by contract: never throws — a failed materialisation must
70
+ * not block heartbeats. A missing/undefined bundle means "server
71
+ * agrees with our applied signature": no work to do.
72
+ */
73
+ handle(bundle) {
74
+ if (!bundle)
75
+ return;
76
+ if (typeof bundle.signature !== "string" || bundle.signature.trim() === "") {
77
+ logger.warn(`Credential bundle delivery missing signature; ignoring`);
78
+ return;
79
+ }
80
+ let result = null;
81
+ try {
82
+ result = applyRuntimeCredentials(bundle.body, { agentName: this.agentName });
83
+ }
84
+ catch (err) {
85
+ logger.warn(`Credential bundle apply failed: ${err instanceof Error ? err.message : String(err)}`);
86
+ // Deliberately do NOT update `_appliedSignature` on failure: the
87
+ // server should keep retrying the bundle until we report success.
88
+ return;
89
+ }
90
+ // Remember the new signature even when the bundle body was empty
91
+ // (e.g. server has no credentials provisioned yet). This is an
92
+ // intentional policy choice: empty bundle means "no new server-
93
+ // issued credential to apply", NOT "tear down any local tunnel
94
+ // currently on disk". The agent therefore keeps its local tunnel
95
+ // materialization as-is and simply records agreement on the empty
96
+ // server state for the rest of this process lifetime.
97
+ this._appliedSignature = bundle.signature.trim();
98
+ // If the apply rotated cloudflared's UUID, bounce it. Without this
99
+ // obs/server.ts spawned cloudflared against whatever was on disk at
100
+ // startup, and a fresh UUID would sit unused until the next
101
+ // process restart.
102
+ if (result.tunnelApplied && result.tunnelChanged && result.tunnel) {
103
+ this.triggerRestart(result);
104
+ }
105
+ }
106
+ /**
107
+ * Kick `restartTunnel` in the background, coalescing concurrent
108
+ * invocations. Logged at `system` level because a restart is a
109
+ * visible event for operators tailing pm2 logs.
110
+ *
111
+ * If a restart is already in flight when we're called, we don't drop
112
+ * the trigger — the in-flight restart sampled disk before this newer
113
+ * credential was materialised and would launch cloudflared against
114
+ * the stale UUID. Instead we record the latest result in
115
+ * `_pendingRestart` and re-kick from the in-flight restart's
116
+ * `finally` block, which guarantees one extra restart that picks up
117
+ * whatever's now on disk.
118
+ */
119
+ triggerRestart(result) {
120
+ if (!this.restartTunnel || !result.tunnel)
121
+ return;
122
+ if (this._restartInFlight) {
123
+ // Always overwrite — only the most recent credential matters; any
124
+ // intermediate bundles will be on disk by the time the follow-up
125
+ // restart re-probes via `obs/server.ts:resolveTunnelOpts()`.
126
+ this._pendingRestart = result;
127
+ return;
128
+ }
129
+ const newTunnelId = result.tunnel.tunnelId;
130
+ const previousTunnelId = result.previousTunnelId;
131
+ const rotationLabel = previousTunnelId === null
132
+ ? "initial"
133
+ : previousTunnelId === newTunnelId
134
+ ? "refreshed"
135
+ : `rotated (${previousTunnelId} → ${newTunnelId})`;
136
+ logger.system(`Runtime credential: tunnel ${rotationLabel}; restarting cloudflared`);
137
+ const restart = this.restartTunnel;
138
+ this._restartInFlight = (async () => {
139
+ try {
140
+ await restart();
141
+ }
142
+ catch (err) {
143
+ logger.warn(`Failed to restart tunnel after credential update: ${err instanceof Error ? err.message : String(err)}`);
144
+ }
145
+ finally {
146
+ this._restartInFlight = null;
147
+ // Drain any rotation that landed mid-flight. We re-enter
148
+ // triggerRestart synchronously (not via setImmediate) so the
149
+ // follow-up restart starts as soon as the previous Promise
150
+ // resolves, but `_restartInFlight` is already null above so the
151
+ // recursive call goes down the spawn path, not the coalesce path.
152
+ const pending = this._pendingRestart;
153
+ if (pending) {
154
+ this._pendingRestart = null;
155
+ this.triggerRestart(pending);
156
+ }
157
+ }
158
+ })();
159
+ }
160
+ }
161
+ //# sourceMappingURL=credential-bundle-handler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credential-bundle-handler.js","sourceRoot":"","sources":["../../src/agent/credential-bundle-handler.ts"],"names":[],"mappings":"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"}
@@ -10,6 +10,20 @@ import type { ChannelManager } from "../channels/manager.js";
10
10
  import { type HeartbeatBillingPlan, type SubscriptionStatus } from "../onboarding/onboarding-service-client.js";
11
11
  import { CommandDispatcher } from "./command-dispatcher.js";
12
12
  import { type PendingUpgradeInfo } from "../config/index.js";
13
+ export interface HeartbeatManagerOptions {
14
+ /**
15
+ * Callback that bounces the cloudflared tunnel (typically the
16
+ * `restartTunnel` returned from `startObsServer`). Forwarded verbatim
17
+ * to the tunnel credential handler — HeartbeatManager itself has no
18
+ * tunnel knowledge beyond passing this through.
19
+ *
20
+ * HeartbeatManager is constructed before `agentState` is initialised
21
+ * (the heartbeat fires during `init` while the agent state is still
22
+ * being built), so we take the callback directly rather than reaching
23
+ * into `getAgentState().restartTunnel`.
24
+ */
25
+ restartTunnel?: () => Promise<string | null>;
26
+ }
13
27
  export declare class HeartbeatManager {
14
28
  private client;
15
29
  readonly commandDispatcher: CommandDispatcher;
@@ -20,23 +34,15 @@ export declare class HeartbeatManager {
20
34
  private _lastBillingPlans;
21
35
  private _lastBillingMessage;
22
36
  /**
23
- * Arms the next heartbeat to request a full `runtimeCredentials` bundle.
24
- * True at startup and whenever the agent detects that local tunnel
25
- * credentials are missing (lost files, first-run, post-reset).
37
+ * Owns all server-issued credential orchestration: signature tracking,
38
+ * bundle materialisation, restart coalescing. HeartbeatManager hands
39
+ * it each heartbeat response and reads its `appliedSignature` when
40
+ * building the next payload.
26
41
  */
27
- private _needsCredentialBootstrap;
28
- constructor(config: VisionClawConfig);
42
+ private readonly credentialBundle;
43
+ constructor(config: VisionClawConfig, options?: HeartbeatManagerOptions);
29
44
  /** Build a base heartbeat payload including owner info for server-side backfill. */
30
45
  private buildPayload;
31
- /**
32
- * Apply any `runtimeCredentials` delivered by the server.
33
- *
34
- * Runs inside a try/catch so a failed materialization never breaks the
35
- * heartbeat critical path. If the server omitted `runtimeCredentials` but
36
- * the agent has no local tunnel credential either, we arm the next
37
- * heartbeat to request a bootstrap refresh.
38
- */
39
- private handleRuntimeCredentialsFromResponse;
40
46
  /**
41
47
  * Initialize the heartbeat client and validate the API key.
42
48
  * Should be called once at startup. If validation fails, heartbeats
@@ -1 +1 @@
1
- {"version":3,"file":"heartbeat-manager.d.ts","sourceRoot":"","sources":["../../src/agent/heartbeat-manager.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAC3D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAC7D,OAAO,EAEL,KAAK,oBAAoB,EAGzB,KAAK,kBAAkB,EACxB,MAAM,4CAA4C,CAAC;AACpD,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAE5D,OAAO,EAA4D,KAAK,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAmDvH,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,MAAM,CAAwC;IACtD,QAAQ,CAAC,iBAAiB,EAAE,iBAAiB,CAAC;IAC9C,OAAO,CAAC,YAAY,CAA+C;IACnE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAmB;IAC1C,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,uBAAuB,CAA8B;IAC7D,OAAO,CAAC,iBAAiB,CAA8B;IACvD,OAAO,CAAC,mBAAmB,CAAqB;IAChD;;;;OAIG;IACH,OAAO,CAAC,yBAAyB,CAAQ;gBAE7B,MAAM,EAAE,gBAAgB;IAQpC,oFAAoF;IACpF,OAAO,CAAC,YAAY;IA0BpB;;;;;;;OAOG;IACH,OAAO,CAAC,oCAAoC;IA0B5C;;;;OAIG;IACG,IAAI,CACR,cAAc,EAAE,cAAc,EAC9B,cAAc,EAAE,MAAM,GAAG,SAAS,EAClC,YAAY,EAAE,kBAAkB,GAAG,IAAI,GACtC,OAAO,CAAC,IAAI,CAAC;IA+EhB,4CAA4C;IAC5C,IAAI,OAAO,IAAI,OAAO,CAErB;IAED,uEAAuE;IACvE,IAAI,sBAAsB,IAAI,kBAAkB,CAE/C;IAED,sFAAsF;IACtF,IAAI,gBAAgB,IAAI,oBAAoB,EAAE,CAE7C;IAED,wFAAwF;IACxF,IAAI,kBAAkB,IAAI,MAAM,GAAG,SAAS,CAE3C;IAED;;;OAGG;IACG,gBAAgB,IAAI,OAAO,CAAC;QAChC,MAAM,EAAE,MAAM,CAAC;QACf,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;QACxB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;QAChC,SAAS,CAAC,EAAE,MAAM,CAAC;KACpB,GAAG,IAAI,CAAC;IAKT;;;OAGG;IACG,MAAM,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAsC1C;;;OAGG;IACG,4BAA4B,IAAI,OAAO,CAAC,kBAAkB,CAAC;IAoBjE;;;OAGG;IACH,iBAAiB,IAAI,IAAI;IAWzB;;;OAGG;IACH,gBAAgB,IAAI,IAAI;CAYzB"}
1
+ {"version":3,"file":"heartbeat-manager.d.ts","sourceRoot":"","sources":["../../src/agent/heartbeat-manager.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAC3D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAC7D,OAAO,EAEL,KAAK,oBAAoB,EAEzB,KAAK,kBAAkB,EACxB,MAAM,4CAA4C,CAAC;AACpD,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAE5D,OAAO,EAA4D,KAAK,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAYvH,MAAM,WAAW,uBAAuB;IACtC;;;;;;;;;;OAUG;IACH,aAAa,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CAC9C;AAsCD,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,MAAM,CAAwC;IACtD,QAAQ,CAAC,iBAAiB,EAAE,iBAAiB,CAAC;IAC9C,OAAO,CAAC,YAAY,CAA+C;IACnE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAmB;IAC1C,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,uBAAuB,CAA8B;IAC7D,OAAO,CAAC,iBAAiB,CAA8B;IACvD,OAAO,CAAC,mBAAmB,CAAqB;IAChD;;;;;OAKG;IACH,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAA0B;gBAE/C,MAAM,EAAE,gBAAgB,EAAE,OAAO,CAAC,EAAE,uBAAuB;IAYvE,oFAAoF;IACpF,OAAO,CAAC,YAAY;IA+BpB;;;;OAIG;IACG,IAAI,CACR,cAAc,EAAE,cAAc,EAC9B,cAAc,EAAE,MAAM,GAAG,SAAS,EAClC,YAAY,EAAE,kBAAkB,GAAG,IAAI,GACtC,OAAO,CAAC,IAAI,CAAC;IAgFhB,4CAA4C;IAC5C,IAAI,OAAO,IAAI,OAAO,CAErB;IAED,uEAAuE;IACvE,IAAI,sBAAsB,IAAI,kBAAkB,CAE/C;IAED,sFAAsF;IACtF,IAAI,gBAAgB,IAAI,oBAAoB,EAAE,CAE7C;IAED,wFAAwF;IACxF,IAAI,kBAAkB,IAAI,MAAM,GAAG,SAAS,CAE3C;IAED;;;OAGG;IACG,gBAAgB,IAAI,OAAO,CAAC;QAChC,MAAM,EAAE,MAAM,CAAC;QACf,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;QACxB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;QAChC,SAAS,CAAC,EAAE,MAAM,CAAC;KACpB,GAAG,IAAI,CAAC;IAKT;;;OAGG;IACG,MAAM,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAgC1C;;;OAGG;IACG,4BAA4B,IAAI,OAAO,CAAC,kBAAkB,CAAC;IAoBjE;;;OAGG;IACH,iBAAiB,IAAI,IAAI;IAWzB;;;OAGG;IACH,gBAAgB,IAAI,IAAI;CAYzB"}