talon-agent 5.20.1 → 5.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/package.json +2 -2
- package/src/cli/commands/mesh-audit.ts +89 -0
- package/src/cli/commands/mesh.ts +22 -1
- package/src/cli/index.ts +1 -1
- package/src/cli/status.ts +24 -1
- package/src/core/config/index.ts +12 -11
- package/src/core/engine/gateway-actions/fetch-url/guard.ts +4 -4
- package/src/core/engine/gateway-actions/fetch-url/index.ts +4 -3
- package/src/core/engine/gateway-actions/mesh.ts +2 -0
- package/src/core/engine/gateway-routes.ts +16 -0
- package/src/core/mesh/audit.ts +232 -0
- package/src/core/mesh/credentials/index.ts +1 -0
- package/src/core/mesh/credentials/store.ts +42 -1
- package/src/core/mesh/credentials/types.ts +21 -2
- package/src/core/mesh/devices/service.ts +103 -5
- package/src/core/mesh/links/bridge-links.ts +94 -9
- package/src/core/mesh/links/node-provision.ts +285 -41
- package/src/core/mesh/transfers/device-files.ts +110 -39
- package/src/core/mesh/transfers/transfers.ts +31 -15
- package/src/core/tools/ops/mesh.ts +24 -4
- package/src/core/weaver/weaver.ts +4 -1
- package/src/frontend/native/bridge/credentials/upgrade.ts +16 -3
- package/src/frontend/native/bridge/routes/host.ts +12 -2
- package/src/frontend/native/bridge/routes/pre-auth.ts +8 -2
- package/src/frontend/native/bridge/server.ts +8 -0
- package/src/frontend/native/bridge/tls.ts +15 -0
- package/src/frontend/native/index.ts +26 -0
- package/src/frontend/native/surface/handlers.ts +1 -1
- package/src/util/logging/turn-scope.ts +23 -2
|
@@ -12,15 +12,21 @@
|
|
|
12
12
|
* daemon → device (command): upload_file { token, path }
|
|
13
13
|
* device → daemon (HTTP): POST /devices/file?transfer=token (raw body)
|
|
14
14
|
* bridge route: acceptUpload(token, stream) → tmp+rename
|
|
15
|
-
* device → daemon (command result): ok + bytes — the command
|
|
16
|
-
* doubles as the completion signal
|
|
15
|
+
* device → daemon (command result): ok + bytes + sha256 — the command
|
|
16
|
+
* round trip doubles as the completion signal, and the daemon checks the
|
|
17
|
+
* device's digest against the one it computed while receiving.
|
|
17
18
|
*
|
|
18
19
|
* push (daemon → device):
|
|
19
20
|
* daemon: token = createPush(deviceId, sourcePath)
|
|
20
|
-
* daemon → device (command): download_file { token, path }
|
|
21
|
+
* daemon → device (command): download_file { token, path, sha256 }
|
|
21
22
|
* device → daemon (HTTP): GET /devices/file?transfer=token
|
|
22
23
|
* bridge route: openDownload(token) → stream the source
|
|
23
|
-
* device
|
|
24
|
+
* device hashes while writing its temp file, refuses to rename it into
|
|
25
|
+
* place on a digest mismatch, and answers with ok + bytes + sha256.
|
|
26
|
+
*
|
|
27
|
+
* The digests are additive: a device build that predates them ignores the
|
|
28
|
+
* push param and omits the pull result key, and the daemon then skips the
|
|
29
|
+
* check.
|
|
24
30
|
*
|
|
25
31
|
* Tokens are single-use, bound to one device + one path, and expire unused.
|
|
26
32
|
* The registry never trusts the HTTP caller with a path — the token IS the
|
|
@@ -30,7 +36,7 @@
|
|
|
30
36
|
* itself must name the device the token was minted for (see take()).
|
|
31
37
|
*/
|
|
32
38
|
|
|
33
|
-
import { randomBytes } from "node:crypto";
|
|
39
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
34
40
|
import { createWriteStream } from "node:fs";
|
|
35
41
|
import { mkdir, rename, rm, stat } from "node:fs/promises";
|
|
36
42
|
import { dirname } from "node:path";
|
|
@@ -40,6 +46,10 @@ import type { Readable } from "node:stream";
|
|
|
40
46
|
/** Unused tokens die after this long (transfer not started). */
|
|
41
47
|
const TOKEN_TTL_MS = 10 * 60 * 1000;
|
|
42
48
|
|
|
49
|
+
/** What a finished pull delivered: its size and the SHA-256 (hex) of the
|
|
50
|
+
* bytes as they arrived, hashed in stream. */
|
|
51
|
+
export type PullReceipt = { bytes: number; sha256: string };
|
|
52
|
+
|
|
43
53
|
type Transfer = {
|
|
44
54
|
token: string;
|
|
45
55
|
direction: "pull" | "push";
|
|
@@ -49,10 +59,10 @@ type Transfer = {
|
|
|
49
59
|
createdAt: number;
|
|
50
60
|
/** Set once the HTTP leg has started (single-use latch). */
|
|
51
61
|
consumed: boolean;
|
|
52
|
-
/** pull only — resolved by acceptUpload with
|
|
62
|
+
/** pull only — resolved by acceptUpload with what arrived. */
|
|
53
63
|
uploadDone?: {
|
|
54
|
-
promise: Promise<
|
|
55
|
-
resolve: (
|
|
64
|
+
promise: Promise<PullReceipt>;
|
|
65
|
+
resolve: (receipt: PullReceipt) => void;
|
|
56
66
|
reject: (err: Error) => void;
|
|
57
67
|
};
|
|
58
68
|
/** pull only — tears down the upload once its HTTP leg is streaming. */
|
|
@@ -63,16 +73,17 @@ export class TransferStore {
|
|
|
63
73
|
private readonly transfers = new Map<string, Transfer>();
|
|
64
74
|
|
|
65
75
|
/** Arrange a device→daemon transfer. Returns the token to send to the
|
|
66
|
-
* device and a promise that resolves (bytes) when the upload
|
|
76
|
+
* device and a promise that resolves (bytes + digest) when the upload
|
|
77
|
+
* lands. */
|
|
67
78
|
createPull(
|
|
68
79
|
deviceId: string,
|
|
69
80
|
destPath: string,
|
|
70
|
-
): { token: string; done: Promise<
|
|
81
|
+
): { token: string; done: Promise<PullReceipt> } {
|
|
71
82
|
this.sweep();
|
|
72
83
|
const token = randomBytes(24).toString("base64url");
|
|
73
|
-
let resolve!: (
|
|
84
|
+
let resolve!: (receipt: PullReceipt) => void;
|
|
74
85
|
let reject!: (err: Error) => void;
|
|
75
|
-
const promise = new Promise<
|
|
86
|
+
const promise = new Promise<PullReceipt>((res, rej) => {
|
|
76
87
|
resolve = res;
|
|
77
88
|
reject = rej;
|
|
78
89
|
});
|
|
@@ -125,7 +136,8 @@ export class TransferStore {
|
|
|
125
136
|
/**
|
|
126
137
|
* Bridge route: a device is streaming a pull's file body up. Writes to a
|
|
127
138
|
* temp file and renames into place, so a dropped connection can't leave a
|
|
128
|
-
* half-written destination. Resolves the pull's `done` promise
|
|
139
|
+
* half-written destination. Resolves the pull's `done` promise with the
|
|
140
|
+
* SHA-256 of the bytes, hashed as they stream through (no second read).
|
|
129
141
|
*
|
|
130
142
|
* `fromDeviceId` is the device the HTTP caller says it is (see take()).
|
|
131
143
|
*/
|
|
@@ -143,14 +155,18 @@ export class TransferStore {
|
|
|
143
155
|
try {
|
|
144
156
|
await mkdir(dirname(t.localPath), { recursive: true });
|
|
145
157
|
let bytes = 0;
|
|
146
|
-
|
|
158
|
+
const hash = createHash("sha256");
|
|
159
|
+
body.on("data", (d: Buffer) => {
|
|
160
|
+
bytes += d.length;
|
|
161
|
+
hash.update(d);
|
|
162
|
+
});
|
|
147
163
|
await pipeline(body, createWriteStream(tmp, { mode: 0o600 }), {
|
|
148
164
|
signal: abort.signal,
|
|
149
165
|
});
|
|
150
166
|
abort.signal.throwIfAborted();
|
|
151
167
|
await rename(tmp, t.localPath);
|
|
152
168
|
this.transfers.delete(token);
|
|
153
|
-
t.uploadDone?.resolve(bytes);
|
|
169
|
+
t.uploadDone?.resolve({ bytes, sha256: hash.digest("hex") });
|
|
154
170
|
return { ok: true, bytes };
|
|
155
171
|
} catch (err) {
|
|
156
172
|
await rm(tmp, { force: true }).catch(() => {});
|
|
@@ -181,6 +181,12 @@ export const meshTools: ToolDefinition[] = [
|
|
|
181
181
|
.describe(
|
|
182
182
|
"Where to stage the APK on the device (default /sdcard/Download/talon-companion-update.apk).",
|
|
183
183
|
),
|
|
184
|
+
allow_downgrade: z
|
|
185
|
+
.boolean()
|
|
186
|
+
.optional()
|
|
187
|
+
.describe(
|
|
188
|
+
"Install even when the APK has a lower versionCode (pm install -d), for a deliberate rollback.",
|
|
189
|
+
),
|
|
184
190
|
},
|
|
185
191
|
execute: (params, bridge) => bridge("update_device", params),
|
|
186
192
|
tag: "mesh",
|
|
@@ -203,6 +209,12 @@ export const meshTools: ToolDefinition[] = [
|
|
|
203
209
|
.describe(
|
|
204
210
|
"Where to stage the binary on the node (default /tmp/talon-node.update; the node re-stages next to its own executable before the atomic swap).",
|
|
205
211
|
),
|
|
212
|
+
allow_downgrade: z
|
|
213
|
+
.boolean()
|
|
214
|
+
.optional()
|
|
215
|
+
.describe(
|
|
216
|
+
"Install even when the binary is an older version, or the exact build already running. The node refuses both without it.",
|
|
217
|
+
),
|
|
206
218
|
},
|
|
207
219
|
execute: (params, bridge) => bridge("update_node", params),
|
|
208
220
|
tag: "mesh",
|
|
@@ -223,12 +235,20 @@ export const meshTools: ToolDefinition[] = [
|
|
|
223
235
|
{
|
|
224
236
|
name: "make_node_install_link",
|
|
225
237
|
description:
|
|
226
|
-
"Mint a single-use install link served by this daemon's bridge and return the one command that attaches a fresh Linux/macOS/Windows host to the mesh as a headless talon-node. Running it on the host downloads the installer script and binary from the bridge (sha256-verified), installs talon-node, pre-pins the bridge TLS certificate, embeds the bearer token, and registers a boot service — no toolchain, package manager, or manual config on the host. The link expires in 30 minutes and each leg serves exactly once; the host only needs to reach the bridge URL. Requires the native bridge running on a non-loopback bind with a token.",
|
|
238
|
+
"Mint a single-use install link served by this daemon's bridge and return the one command that attaches a fresh Linux/macOS/Windows host to the mesh as a headless talon-node. Running it on the host downloads the installer script and binary from the bridge (sha256-verified), installs talon-node, pre-pins the bridge TLS certificate, embeds the bearer token, and registers a boot service — no toolchain, package manager, or manual config on the host. Omit os and arch when you don't know the host: you get a POSIX and a PowerShell command, and whichever runs reports its own platform so the bridge picks the right binary. The link expires in 30 minutes and each leg serves exactly once; the host only needs to reach the bridge URL. Requires the native bridge running on a non-loopback bind with a token.",
|
|
227
239
|
schema: {
|
|
228
|
-
os: z
|
|
240
|
+
os: z
|
|
241
|
+
.string()
|
|
242
|
+
.optional()
|
|
243
|
+
.describe(
|
|
244
|
+
"Host OS: linux, macos/darwin, or windows. Omit (with arch) to auto-detect on the host.",
|
|
245
|
+
),
|
|
229
246
|
arch: z
|
|
230
247
|
.string()
|
|
231
|
-
.
|
|
248
|
+
.optional()
|
|
249
|
+
.describe(
|
|
250
|
+
"Host arch: amd64/x86_64, arm64/aarch64, or arm. Omit (with os) to auto-detect on the host.",
|
|
251
|
+
),
|
|
232
252
|
name: z
|
|
233
253
|
.string()
|
|
234
254
|
.optional()
|
|
@@ -239,7 +259,7 @@ export const meshTools: ToolDefinition[] = [
|
|
|
239
259
|
.string()
|
|
240
260
|
.optional()
|
|
241
261
|
.describe(
|
|
242
|
-
"Bridge base URL as reachable FROM the new host (e.g. https://100.64.0.7:19880). Default: derived from the bridge bind (wildcard binds use this host's first external IPv4).",
|
|
262
|
+
"Bridge base URL as reachable FROM the new host (e.g. https://100.64.0.7:19880). Default: native.publicUrl when set, else derived from the bridge bind (wildcard binds use this host's first external IPv4).",
|
|
243
263
|
),
|
|
244
264
|
},
|
|
245
265
|
execute: (params, bridge) => bridge("make_node_install_link", params),
|
|
@@ -99,7 +99,10 @@ export class Weaver {
|
|
|
99
99
|
const lifecycle = { started: false, killed: false, enqueuedAt: Date.now() };
|
|
100
100
|
// The turn id is minted at enqueue so a queued turn's wait is already
|
|
101
101
|
// attributable; the log scope goes live when the turn starts running.
|
|
102
|
-
const scope = createTurnScope(params.chatId
|
|
102
|
+
const scope = createTurnScope(params.chatId, {
|
|
103
|
+
sender: params.senderKeys?.[0] ?? (params.senderName || undefined),
|
|
104
|
+
source: params.source,
|
|
105
|
+
});
|
|
103
106
|
const trace = createTurnTrace(scope.turnId, params, thread.inFlightCount);
|
|
104
107
|
// Registered before enqueueing so a turn waiting in its chat's FIFO is
|
|
105
108
|
// visible as `queued` in the task table, not invisible until it runs.
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* shared token → a per-device credential bound to the device id in the
|
|
7
7
|
* body (the migration path off `native.token`). Scopes are what the
|
|
8
8
|
* client asks for, capped by policy: a node gets `device`; a companion
|
|
9
|
-
* at most `native.companionScopes` (default
|
|
10
|
-
*
|
|
9
|
+
* at most `native.companionScopes` (default: all three, as the shared
|
|
10
|
+
* token it is trading carried). A companion that asks for `client`
|
|
11
|
+
* also gets `operator` whenever that list allows it.
|
|
11
12
|
*
|
|
12
13
|
* per-device credential → a replacement with the same device and scopes
|
|
13
14
|
* (rotation, when the operator asked for it or the client wants one).
|
|
@@ -48,7 +49,19 @@ function grantFor(
|
|
|
48
49
|
const ceiling: readonly BridgeScope[] =
|
|
49
50
|
client === "node" ? ["device"] : credentials.policy.companionScopes;
|
|
50
51
|
if (asked.length === 0) return [...ceiling];
|
|
51
|
-
|
|
52
|
+
const granted = asked.filter((s) => ceiling.includes(s));
|
|
53
|
+
// Shipped companions ask for device + client: the list predates operator
|
|
54
|
+
// being part of the default. The companion is the full UI (settings,
|
|
55
|
+
// extensions), so it gets operator whenever the policy allows it.
|
|
56
|
+
if (
|
|
57
|
+
client !== "node" &&
|
|
58
|
+
granted.includes("client") &&
|
|
59
|
+
ceiling.includes("operator") &&
|
|
60
|
+
!granted.includes("operator")
|
|
61
|
+
) {
|
|
62
|
+
granted.push("operator");
|
|
63
|
+
}
|
|
64
|
+
return granted;
|
|
52
65
|
}
|
|
53
66
|
|
|
54
67
|
export async function upgradeCredential(
|
|
@@ -149,8 +149,18 @@ export type BridgeServerHandlers = {
|
|
|
149
149
|
token: string,
|
|
150
150
|
format: "html" | "json",
|
|
151
151
|
): { contentType: string; body: string } | null;
|
|
152
|
-
/**
|
|
153
|
-
|
|
152
|
+
/**
|
|
153
|
+
* Resolve a node-provisioning token to its installer script, or null. An
|
|
154
|
+
* auto link passes the os/arch its host reported.
|
|
155
|
+
*/
|
|
156
|
+
openNodeInstall(
|
|
157
|
+
token: string,
|
|
158
|
+
os?: string | null,
|
|
159
|
+
arch?: string | null,
|
|
160
|
+
):
|
|
161
|
+
| { script: string; filename: string }
|
|
162
|
+
| null
|
|
163
|
+
| Promise<{ script: string; filename: string } | null>;
|
|
154
164
|
/** Resolve a node-provisioning token to the binary to stream, or null. */
|
|
155
165
|
openNodeBinary(token: string): { path: string; size: number } | null;
|
|
156
166
|
};
|
|
@@ -73,9 +73,15 @@ export function preAuthRoutes(
|
|
|
73
73
|
// the single-use grant token (minted by make_node_install_link,
|
|
74
74
|
// expiring, one serve per leg) is the entire authorization, the same
|
|
75
75
|
// trust model as streamed-transfer tokens.
|
|
76
|
-
"GET /node/install": ({ res, url }) => {
|
|
76
|
+
"GET /node/install": async ({ res, url }) => {
|
|
77
77
|
const token = url.searchParams.get("provision") ?? "";
|
|
78
|
-
const install = token
|
|
78
|
+
const install = token
|
|
79
|
+
? await h.openNodeInstall(
|
|
80
|
+
token,
|
|
81
|
+
url.searchParams.get("os"),
|
|
82
|
+
url.searchParams.get("arch"),
|
|
83
|
+
)
|
|
84
|
+
: null;
|
|
79
85
|
if (!install) return host.unknownProvision(res);
|
|
80
86
|
res.writeHead(200, {
|
|
81
87
|
"Content-Type": "text/plain; charset=utf-8",
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from "../../../core/frontend-runtime/alerts.js";
|
|
32
32
|
import { errorText } from "../../health/outage.js";
|
|
33
33
|
import {
|
|
34
|
+
certificateSpkiPin,
|
|
34
35
|
formatFingerprint,
|
|
35
36
|
isLoopbackHost,
|
|
36
37
|
type BridgeTlsIdentity,
|
|
@@ -196,6 +197,13 @@ export class BridgeServer {
|
|
|
196
197
|
return this.tlsIdentity?.fingerprint ?? null;
|
|
197
198
|
}
|
|
198
199
|
|
|
200
|
+
/** The served key's SPKI pin (base64 SHA-256), or null over HTTP. */
|
|
201
|
+
getSpkiPin(): string | null {
|
|
202
|
+
return this.tlsIdentity
|
|
203
|
+
? certificateSpkiPin(this.tlsIdentity.certPem)
|
|
204
|
+
: null;
|
|
205
|
+
}
|
|
206
|
+
|
|
199
207
|
/**
|
|
200
208
|
* Push an event to every connected SSE client that may see it. Chat
|
|
201
209
|
* traffic is for `client`-scoped sessions only; a device-only credential
|
|
@@ -228,6 +228,21 @@ export function certificateFingerprint(certPem: string): string {
|
|
|
228
228
|
.digest("hex");
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
/**
|
|
232
|
+
* The certificate's public-key pin: base64 SHA-256 of its DER
|
|
233
|
+
* SubjectPublicKeyInfo — the value curl's `--pinnedpubkey sha256//…` checks.
|
|
234
|
+
*/
|
|
235
|
+
export function certificateSpkiPin(certPem: string): string {
|
|
236
|
+
return createHash("sha256")
|
|
237
|
+
.update(
|
|
238
|
+
new X509Certificate(certPem).publicKey.export({
|
|
239
|
+
type: "spki",
|
|
240
|
+
format: "der",
|
|
241
|
+
}),
|
|
242
|
+
)
|
|
243
|
+
.digest("base64");
|
|
244
|
+
}
|
|
245
|
+
|
|
231
246
|
/** AA:BB:… presentation of a fingerprint, for logs and pairing screens. */
|
|
232
247
|
export function formatFingerprint(fingerprint: string): string {
|
|
233
248
|
return (fingerprint.match(/.{2}/g) ?? []).join(":").toUpperCase();
|
|
@@ -33,6 +33,7 @@ import { createNativeRuntime, type NativeRuntime } from "./runtime.js";
|
|
|
33
33
|
import { BridgeServer, type BridgeCredentials } from "./bridge/server.js";
|
|
34
34
|
import {
|
|
35
35
|
DEFAULT_COMPANION_SCOPES,
|
|
36
|
+
FORMER_COMPANION_SCOPES,
|
|
36
37
|
type MeshScope,
|
|
37
38
|
} from "../../core/mesh/credentials/index.js";
|
|
38
39
|
import { isLoopbackHost, loadOrCreateBridgeTlsIdentity } from "./bridge/tls.js";
|
|
@@ -106,6 +107,28 @@ function bridgeCredentials(
|
|
|
106
107
|
};
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Companions paired while the default was device + client get the current
|
|
112
|
+
* default too — unless the operator set `native.companionScopes` (their
|
|
113
|
+
* choice then stands) or set that device's scopes by hand.
|
|
114
|
+
*/
|
|
115
|
+
async function adoptCompanionDefault(
|
|
116
|
+
config: TalonConfig,
|
|
117
|
+
mesh: NativeRuntime["mesh"],
|
|
118
|
+
): Promise<void> {
|
|
119
|
+
if (config.native?.companionScopes || !mesh.credentials) return;
|
|
120
|
+
const moved = await mesh.credentials.adoptDefaultScopes(
|
|
121
|
+
FORMER_COMPANION_SCOPES,
|
|
122
|
+
DEFAULT_COMPANION_SCOPES,
|
|
123
|
+
);
|
|
124
|
+
if (moved > 0) {
|
|
125
|
+
log(
|
|
126
|
+
"native",
|
|
127
|
+
`Granted the default companion scopes (${DEFAULT_COMPANION_SCOPES.join(", ")}) to ${moved} credential(s) issued under the old device + client default; set native.companionScopes to narrow them.`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
109
132
|
/**
|
|
110
133
|
* Plug this bridge in as the mesh's transport: locates and device commands
|
|
111
134
|
* (from ANY frontend's mesh tool calls) leave as SSE events.
|
|
@@ -189,6 +212,7 @@ export function createNativeFrontend(
|
|
|
189
212
|
|
|
190
213
|
async init() {
|
|
191
214
|
await mesh.load();
|
|
215
|
+
await adoptCompanionDefault(config, mesh);
|
|
192
216
|
unregisterMeshTransport = registerMeshTransport(runtime, server);
|
|
193
217
|
// Mesh tool actions (list_devices / get_device_location) are shared
|
|
194
218
|
// gateway actions — no native-only cases here.
|
|
@@ -226,6 +250,7 @@ export function createNativeFrontend(
|
|
|
226
250
|
);
|
|
227
251
|
}
|
|
228
252
|
const fingerprint = server.getFingerprint();
|
|
253
|
+
const spkiPin = server.getSpkiPin();
|
|
229
254
|
// Tell the mesh how this bridge is reachable — everything a generated
|
|
230
255
|
// node installer needs (make_node_install_link fails cleanly without it).
|
|
231
256
|
mesh.setBridgeInfo({
|
|
@@ -234,6 +259,7 @@ export function createNativeFrontend(
|
|
|
234
259
|
port: server.getPort(),
|
|
235
260
|
...(listen.token ? { token: listen.token } : {}),
|
|
236
261
|
...(fingerprint ? { fingerprint } : {}),
|
|
262
|
+
...(spkiPin ? { spkiPin } : {}),
|
|
237
263
|
...(config.native?.publicUrl
|
|
238
264
|
? { publicUrl: config.native.publicUrl }
|
|
239
265
|
: {}),
|
|
@@ -191,7 +191,7 @@ export function buildBridgeHandlers(
|
|
|
191
191
|
openFileDownload: (token, fromDeviceId) =>
|
|
192
192
|
mesh.openFileDownload(token, fromDeviceId),
|
|
193
193
|
openCompanionPair: (token, format) => mesh.openCompanionPair(token, format),
|
|
194
|
-
openNodeInstall: (token) => mesh.openNodeInstall(token),
|
|
194
|
+
openNodeInstall: (token, os, arch) => mesh.openNodeInstall(token, os, arch),
|
|
195
195
|
openNodeBinary: (token) => mesh.openNodeBinary(token),
|
|
196
196
|
};
|
|
197
197
|
}
|
|
@@ -24,9 +24,18 @@
|
|
|
24
24
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
25
25
|
import { randomBytes } from "node:crypto";
|
|
26
26
|
|
|
27
|
+
/** Who a turn acts for — recorded by audits of what the turn did. */
|
|
28
|
+
export type TurnIssuer = {
|
|
29
|
+
/** The sender's operator key, else display name; absent when unknown. */
|
|
30
|
+
readonly sender?: string;
|
|
31
|
+
/** What started the turn: message, cron, trigger, pulse, agent. */
|
|
32
|
+
readonly source?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
27
35
|
export type TurnLogScope = {
|
|
28
36
|
readonly turnId: string;
|
|
29
37
|
readonly chatId: string;
|
|
38
|
+
readonly issuer: TurnIssuer;
|
|
30
39
|
/** False once the turn has settled — the scope then tags nothing. */
|
|
31
40
|
open: boolean;
|
|
32
41
|
};
|
|
@@ -41,8 +50,11 @@ function mintTurnId(): string {
|
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
/** A fresh scope for a turn that has been accepted but not yet started. */
|
|
44
|
-
export function createTurnScope(
|
|
45
|
-
|
|
53
|
+
export function createTurnScope(
|
|
54
|
+
chatId: string,
|
|
55
|
+
issuer: TurnIssuer = {},
|
|
56
|
+
): TurnLogScope {
|
|
57
|
+
return { turnId: mintTurnId(), chatId, issuer, open: true };
|
|
46
58
|
}
|
|
47
59
|
|
|
48
60
|
/**
|
|
@@ -83,3 +95,12 @@ export function currentTurnId(): string | undefined {
|
|
|
83
95
|
const scope = storage.getStore();
|
|
84
96
|
return scope?.open ? scope.turnId : undefined;
|
|
85
97
|
}
|
|
98
|
+
|
|
99
|
+
/** The live turn for the current async chain — who is acting — if any. */
|
|
100
|
+
export function currentTurn():
|
|
101
|
+
{ turnId: string; chatId: string; issuer: TurnIssuer } | undefined {
|
|
102
|
+
const scope = storage.getStore();
|
|
103
|
+
return scope?.open
|
|
104
|
+
? { turnId: scope.turnId, chatId: scope.chatId, issuer: scope.issuer }
|
|
105
|
+
: undefined;
|
|
106
|
+
}
|