@scp-dev/core 0.1.6 → 0.2.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/dist/index.d.ts +24 -3
- package/dist/index.js +81 -15
- package/package.json +1 -1
- package/src/core.ts +87 -15
- package/src/scopes.ts +3 -2
package/dist/index.d.ts
CHANGED
|
@@ -998,11 +998,12 @@ declare function relationText(peer: PolicySubject, me: PolicySubject): string;
|
|
|
998
998
|
/**
|
|
999
999
|
* The scope check for a message from `a` to `b`: `out(a)` admits `b` and `in(b)` admits `a`. Either side the account's human (with `humanAlways`)
|
|
1000
1000
|
* or the system endpoint passes, except an `unverified` human sender (one without a terminal), which is checked like any unplaced sender; an
|
|
1001
|
-
* ancestor of `b` (unless `strict`)
|
|
1001
|
+
* ancestor of `b` (unless `strict`), a solicited reply and a parent and child `linked` across machines pass both.
|
|
1002
1002
|
*/
|
|
1003
1003
|
declare function scopeDecision(world: ScopeWorld, a: ScopeNode, b: ScopeNode, options: {
|
|
1004
1004
|
humanAlways: boolean;
|
|
1005
1005
|
solicited?: boolean;
|
|
1006
|
+
linked?: boolean;
|
|
1006
1007
|
unverified?: boolean;
|
|
1007
1008
|
strict?: boolean;
|
|
1008
1009
|
}): ScopeDecision;
|
|
@@ -1085,6 +1086,8 @@ interface EndpointRow {
|
|
|
1085
1086
|
/** the root endpoint's id; null for a root */
|
|
1086
1087
|
tree: string | null;
|
|
1087
1088
|
parent_ep: string | null;
|
|
1089
|
+
/** a parent on another machine of the account, `<machine>/<handle>`: the row stays a root of its own tree */
|
|
1090
|
+
parent_addr: string | null;
|
|
1088
1091
|
depth: number | null;
|
|
1089
1092
|
/** JSON {@link ScopeRules} */
|
|
1090
1093
|
rules: string | null;
|
|
@@ -1359,6 +1362,8 @@ declare class RelayCore {
|
|
|
1359
1362
|
waker(endpoint: string, live: boolean, options?: {
|
|
1360
1363
|
grace?: boolean;
|
|
1361
1364
|
}): void;
|
|
1365
|
+
/** clears a wake no waker holds once a hook waker's grace ran out too: nothing is left to wake the endpoint */
|
|
1366
|
+
private lapseWake;
|
|
1362
1367
|
/**
|
|
1363
1368
|
* a title for the caller's in-process subagent `id` from the description its parent gave it; the subagent's endpoint id when it changed one.
|
|
1364
1369
|
* It fills only a title nobody set, as an inferred one, and waits for the subagent's claim when it has no endpoint yet
|
|
@@ -1403,8 +1408,22 @@ declare class RelayCore {
|
|
|
1403
1408
|
endpoint: EndpointView;
|
|
1404
1409
|
parent: string;
|
|
1405
1410
|
};
|
|
1406
|
-
/**
|
|
1411
|
+
/**
|
|
1412
|
+
* `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants; a parent on another
|
|
1413
|
+
* machine goes to {@link linkAcross}
|
|
1414
|
+
*/
|
|
1407
1415
|
private link;
|
|
1416
|
+
/** a link of `row`, which already sits under a parent, to another one */
|
|
1417
|
+
private alreadyLinked;
|
|
1418
|
+
/**
|
|
1419
|
+
* the key `<machine>/<handle>` that links `row`, a local agent, under `parent`, an agent on another machine, once the parent is checked; no
|
|
1420
|
+
* inherited or child-default caps come with it. A relay of one machine has no such parent
|
|
1421
|
+
*/
|
|
1422
|
+
protected linkAcross(_row: EndpointRow, _parent: EndpointRow): string;
|
|
1423
|
+
/** whether `a` and `b` are a parent and its child on different machines */
|
|
1424
|
+
protected linkedAcross(_a: EndpointRow, _b: EndpointRow): boolean;
|
|
1425
|
+
/** the address of `row`'s parent on another machine, null when it has none; with `shown`, null for a parent outside it */
|
|
1426
|
+
protected linkedParent(_row: EndpointRow, _shown?: Set<string>): string | null;
|
|
1408
1427
|
/**
|
|
1409
1428
|
* renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
|
|
1410
1429
|
* the policy's `defaults.rename` is `self`; the human at a terminal renames any local agent. The new handle is valid and held by no other endpoint
|
|
@@ -1419,6 +1438,8 @@ declare class RelayCore {
|
|
|
1419
1438
|
};
|
|
1420
1439
|
/** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
|
|
1421
1440
|
private renameRow;
|
|
1441
|
+
/** a local agent's handle moved from `from` to `to`: a subclass updates what names it on another machine's rows */
|
|
1442
|
+
protected renamedHandle(_from: string, _to: string): void;
|
|
1422
1443
|
/**
|
|
1423
1444
|
* a session leaves: it lets go of its session and token and shows offline, its mail kept; a later join takes it back. An agent leaves only
|
|
1424
1445
|
* itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
|
|
@@ -1700,7 +1721,7 @@ declare class RelayCore {
|
|
|
1700
1721
|
}): AuditRow[];
|
|
1701
1722
|
/** drops what expired: deliveries, message bodies past their grace, old guard state and old audit rows */
|
|
1702
1723
|
sweep(): void;
|
|
1703
|
-
/** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
|
|
1724
|
+
/** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would, and lapses wakes whose hook waker never came back */
|
|
1704
1725
|
endExited(now?: number): void;
|
|
1705
1726
|
/** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
|
|
1706
1727
|
private settleOffline;
|
package/dist/index.js
CHANGED
|
@@ -558,6 +558,7 @@ function scopeDecision(world, a, b, options) {
|
|
|
558
558
|
if (options.humanAlways && (b.human || a.human && !options.unverified)) return { allowed: true, reasons: ["the human is always reachable"] };
|
|
559
559
|
if (!options.strict && isAncestor(a, b)) return { allowed: true, reasons: [`${a.address} is an ancestor of ${b.address}: scopes never stop it`] };
|
|
560
560
|
if (options.solicited) return { allowed: true, reasons: [`a reply to ${b.address}'s message in its thread`] };
|
|
561
|
+
if (options.linked) return { allowed: true, reasons: [`${a.address} and ${b.address} are parent and child across machines`] };
|
|
561
562
|
const memo = /* @__PURE__ */ new Map();
|
|
562
563
|
const out = admits(world, a, "out", b, memo);
|
|
563
564
|
if (!out.ok) return { allowed: false, dir: "out", reasons: [out.why] };
|
|
@@ -1338,6 +1339,7 @@ var RelayCore = class {
|
|
|
1338
1339
|
for (const [column, type] of [
|
|
1339
1340
|
["tree", "TEXT"],
|
|
1340
1341
|
["parent_ep", "TEXT"],
|
|
1342
|
+
["parent_addr", "TEXT"],
|
|
1341
1343
|
["depth", "INTEGER"],
|
|
1342
1344
|
["rules", "TEXT"],
|
|
1343
1345
|
["caps", "TEXT"],
|
|
@@ -1942,7 +1944,7 @@ var RelayCore = class {
|
|
|
1942
1944
|
}
|
|
1943
1945
|
caps.caps.push(...this.capsOf(row).caps);
|
|
1944
1946
|
this.sql.all(
|
|
1945
|
-
`UPDATE endpoints SET tree = ?, parent_ep = ?, depth = ?, rules = ?, caps = ? WHERE id = ?`,
|
|
1947
|
+
`UPDATE endpoints SET tree = ?, parent_ep = ?, parent_addr = NULL, depth = ?, rules = ?, caps = ? WHERE id = ?`,
|
|
1946
1948
|
parent ? parent.tree ?? parent.id : null,
|
|
1947
1949
|
parent?.id ?? null,
|
|
1948
1950
|
depth,
|
|
@@ -2195,13 +2197,20 @@ var RelayCore = class {
|
|
|
2195
2197
|
*/
|
|
2196
2198
|
waker(endpoint, live, options = {}) {
|
|
2197
2199
|
if (live) this.sql.all(`UPDATE endpoints SET wakers = wakers + 1 WHERE id = ?`, endpoint);
|
|
2198
|
-
else
|
|
2200
|
+
else {
|
|
2201
|
+
const now = this.now();
|
|
2199
2202
|
this.sql.all(
|
|
2200
2203
|
`UPDATE endpoints SET wakers = MAX(wakers - 1, 0), waker_left = CASE WHEN ? THEN ? ELSE waker_left END WHERE id = ?`,
|
|
2201
2204
|
options.grace ? 1 : 0,
|
|
2202
|
-
|
|
2205
|
+
now,
|
|
2203
2206
|
endpoint
|
|
2204
2207
|
);
|
|
2208
|
+
if (!options.grace) this.lapseWake(endpoint, now);
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
/** clears a wake no waker holds once a hook waker's grace ran out too: nothing is left to wake the endpoint */
|
|
2212
|
+
lapseWake(endpoint, now) {
|
|
2213
|
+
this.sql.all(`UPDATE endpoints SET wake = NULL WHERE id = ? AND wakers = 0 AND COALESCE(waker_left, 0) < ?`, endpoint, now - LIMITS.restartGraceS * 1e3);
|
|
2205
2214
|
}
|
|
2206
2215
|
/**
|
|
2207
2216
|
* a title for the caller's in-process subagent `id` from the description its parent gave it; the subagent's endpoint id when it changed one.
|
|
@@ -2307,7 +2316,8 @@ var RelayCore = class {
|
|
|
2307
2316
|
const target = params.endpoint === void 0 ? me : this.resolve(params.endpoint);
|
|
2308
2317
|
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent unlinks only itself");
|
|
2309
2318
|
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be unlinked");
|
|
2310
|
-
if (target.parent_ep === null
|
|
2319
|
+
if (target.parent_ep === null && target.parent_addr === null)
|
|
2320
|
+
throw new RelayError("invalid_params", `${this.address(target)} is a root: it has no parent`);
|
|
2311
2321
|
if (actor.kind !== "human" && this.policy?.defaults?.unlink !== "self")
|
|
2312
2322
|
throw this.scopeRefused(
|
|
2313
2323
|
actor,
|
|
@@ -2315,6 +2325,14 @@ var RelayCore = class {
|
|
|
2315
2325
|
{ target: this.address(target), reason: "unlink" },
|
|
2316
2326
|
"unlinking widens a worker's scope: only the human at a terminal unlinks it (policy defaults.unlink: self lets it)"
|
|
2317
2327
|
);
|
|
2328
|
+
if (target.parent_ep === null) {
|
|
2329
|
+
const from2 = this.linkedParent(target) ?? target.parent_addr;
|
|
2330
|
+
this.sql.all(`UPDATE endpoints SET parent_addr = NULL, linked = NULL WHERE id = ?`, target.id);
|
|
2331
|
+
this.scopeMemo?.ancestors.clear();
|
|
2332
|
+
this.scopeMemo?.limited.clear();
|
|
2333
|
+
this.audit("unlink", target.id, null, { ...this.actorDetail(actor, me), target: this.address(target), parent: from2 });
|
|
2334
|
+
return { endpoint: this.whoami(target.id), parent: from2 };
|
|
2335
|
+
}
|
|
2318
2336
|
const parent = this.byId(target.parent_ep);
|
|
2319
2337
|
const from = parent ? this.address(parent) : target.parent_ep;
|
|
2320
2338
|
const shift = target.depth ?? 0;
|
|
@@ -2338,16 +2356,26 @@ var RelayCore = class {
|
|
|
2338
2356
|
return { endpoint: this.whoami(target.id), parent: from };
|
|
2339
2357
|
});
|
|
2340
2358
|
}
|
|
2341
|
-
/**
|
|
2359
|
+
/**
|
|
2360
|
+
* `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants; a parent on another
|
|
2361
|
+
* machine goes to {@link linkAcross}
|
|
2362
|
+
*/
|
|
2342
2363
|
link(row, parent, by) {
|
|
2343
|
-
if (
|
|
2364
|
+
if (!this.localRow(parent)) {
|
|
2365
|
+
const key = this.linkAcross(row, parent);
|
|
2366
|
+
if (row.parent_addr === key) return;
|
|
2367
|
+
if (row.parent_ep !== null || row.parent_addr !== null) throw this.alreadyLinked(row);
|
|
2368
|
+
this.sql.all(`UPDATE endpoints SET parent_addr = ?, linked = ? WHERE id = ?`, key, this.now(), row.id);
|
|
2369
|
+
this.scopeMemo?.ancestors.clear();
|
|
2370
|
+
this.scopeMemo?.limited.clear();
|
|
2371
|
+
this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) });
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
if (parent.kind !== "agent") throw new RelayError("invalid_params", "the parent is an agent endpoint");
|
|
2344
2375
|
if (row.parent_ep === parent.id) return;
|
|
2345
2376
|
if (parent.id === row.id || this.ancestorsOf(parent).includes(row.id))
|
|
2346
2377
|
throw new RelayError("scope_refused", `${this.address(parent)} sits under ${this.address(row)}: a session cannot go under its own descendant`);
|
|
2347
|
-
if (row.parent_ep !== null)
|
|
2348
|
-
const now = this.byId(row.parent_ep);
|
|
2349
|
-
throw new RelayError("scope_refused", `${this.address(row)} already sits under ${now ? this.address(now) : "another agent"}; unlink it first`);
|
|
2350
|
-
}
|
|
2378
|
+
if (row.parent_ep !== null || row.parent_addr !== null) throw this.alreadyLinked(row);
|
|
2351
2379
|
const depth = (parent.depth ?? 0) + 1;
|
|
2352
2380
|
const deepest = one(this.sql, `SELECT MAX(depth) AS d FROM endpoints WHERE tree = ?`, row.id)?.d ?? 0;
|
|
2353
2381
|
if (deepest + depth > LIMITS.depthMax)
|
|
@@ -2371,6 +2399,27 @@ var RelayCore = class {
|
|
|
2371
2399
|
this.scopeMemo?.limited.clear();
|
|
2372
2400
|
this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) });
|
|
2373
2401
|
}
|
|
2402
|
+
/** a link of `row`, which already sits under a parent, to another one */
|
|
2403
|
+
alreadyLinked(row) {
|
|
2404
|
+
const local = row.parent_ep ? this.byId(row.parent_ep) : void 0;
|
|
2405
|
+
const now = local ? this.address(local) : row.parent_addr !== null ? this.linkedParent(row) ?? row.parent_addr : "another agent";
|
|
2406
|
+
return new RelayError("scope_refused", `${this.address(row)} already sits under ${now}; unlink it first`);
|
|
2407
|
+
}
|
|
2408
|
+
/**
|
|
2409
|
+
* the key `<machine>/<handle>` that links `row`, a local agent, under `parent`, an agent on another machine, once the parent is checked; no
|
|
2410
|
+
* inherited or child-default caps come with it. A relay of one machine has no such parent
|
|
2411
|
+
*/
|
|
2412
|
+
linkAcross(_row, _parent) {
|
|
2413
|
+
throw new RelayError("invalid_params", "the parent is a local agent endpoint");
|
|
2414
|
+
}
|
|
2415
|
+
/** whether `a` and `b` are a parent and its child on different machines */
|
|
2416
|
+
linkedAcross(_a, _b) {
|
|
2417
|
+
return false;
|
|
2418
|
+
}
|
|
2419
|
+
/** the address of `row`'s parent on another machine, null when it has none; with `shown`, null for a parent outside it */
|
|
2420
|
+
linkedParent(_row, _shown) {
|
|
2421
|
+
return null;
|
|
2422
|
+
}
|
|
2374
2423
|
/**
|
|
2375
2424
|
* renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
|
|
2376
2425
|
* the policy's `defaults.rename` is `self`; the human at a terminal renames any local agent. The new handle is valid and held by no other endpoint
|
|
@@ -2418,8 +2467,12 @@ var RelayCore = class {
|
|
|
2418
2467
|
])
|
|
2419
2468
|
this.sql.all(`UPDATE ${table} SET ${column} = REPLACE(${column}, ?, ?) WHERE instr(${column}, ?) > 0`, a, b, a);
|
|
2420
2469
|
}
|
|
2470
|
+
this.renamedHandle(from, handle);
|
|
2421
2471
|
this.audit("rename", row.id, null, { from, to: handle });
|
|
2422
2472
|
}
|
|
2473
|
+
/** a local agent's handle moved from `from` to `to`: a subclass updates what names it on another machine's rows */
|
|
2474
|
+
renamedHandle(_from, _to) {
|
|
2475
|
+
}
|
|
2423
2476
|
/**
|
|
2424
2477
|
* a session leaves: it lets go of its session and token and shows offline, its mail kept; a later join takes it back. An agent leaves only
|
|
2425
2478
|
* itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
|
|
@@ -3593,15 +3646,24 @@ var RelayCore = class {
|
|
|
3593
3646
|
if (!root || root.handle.startsWith(GONE_PREFIX) || !seen) this.dropGroup(group.name);
|
|
3594
3647
|
}
|
|
3595
3648
|
}
|
|
3596
|
-
/** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
|
|
3649
|
+
/** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would, and lapses wakes whose hook waker never came back */
|
|
3597
3650
|
endExited(now = this.now()) {
|
|
3598
3651
|
for (const row of this.sql.all(
|
|
3599
3652
|
`SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND hook_seen IS NOT NULL AND process IS NOT NULL`
|
|
3600
3653
|
)) {
|
|
3601
3654
|
const pid = Number(/^pid:(\d+)/.exec(row.process)?.[1]);
|
|
3602
3655
|
if (!pid || !this.localRow(row) || this.alive(pid)) continue;
|
|
3603
|
-
this.sql.all(
|
|
3656
|
+
this.sql.all(
|
|
3657
|
+
`UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = NULL, wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ? AND connections = 0`,
|
|
3658
|
+
now,
|
|
3659
|
+
row.id
|
|
3660
|
+
);
|
|
3604
3661
|
}
|
|
3662
|
+
for (const row of this.sql.all(
|
|
3663
|
+
`SELECT * FROM endpoints WHERE wake IS NOT NULL AND wakers = 0 AND waker_left < ?`,
|
|
3664
|
+
now - LIMITS.restartGraceS * 1e3
|
|
3665
|
+
))
|
|
3666
|
+
if (this.localRow(row)) this.lapseWake(row.id, now);
|
|
3605
3667
|
}
|
|
3606
3668
|
/** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
|
|
3607
3669
|
settleOffline(now) {
|
|
@@ -3651,7 +3713,8 @@ var RelayCore = class {
|
|
|
3651
3713
|
if (params.kind === "session_end") {
|
|
3652
3714
|
if (own)
|
|
3653
3715
|
this.sql.all(
|
|
3654
|
-
`UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END
|
|
3716
|
+
`UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END,
|
|
3717
|
+
wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ?`,
|
|
3655
3718
|
now,
|
|
3656
3719
|
endpoint
|
|
3657
3720
|
);
|
|
@@ -4280,7 +4343,8 @@ var RelayCore = class {
|
|
|
4280
4343
|
}
|
|
4281
4344
|
const note = [`exited ${p.code}`, spender.status_note].filter(Boolean).join("; ").slice(0, LIMITS.noteChars);
|
|
4282
4345
|
this.sql.all(
|
|
4283
|
-
`UPDATE endpoints SET status_state = 'done', status_note = ?, hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END
|
|
4346
|
+
`UPDATE endpoints SET status_state = 'done', status_note = ?, hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END,
|
|
4347
|
+
wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ?`,
|
|
4284
4348
|
note,
|
|
4285
4349
|
now,
|
|
4286
4350
|
spender.id
|
|
@@ -4705,6 +4769,7 @@ ${stderr}` : "";
|
|
|
4705
4769
|
return scopeDecision(world, world.node(a.id) ?? this.scopeNode(a), world.node(b.id) ?? this.scopeNode(b), {
|
|
4706
4770
|
humanAlways: this.defaults().humanAlways,
|
|
4707
4771
|
solicited,
|
|
4772
|
+
linked: a.id !== b.id && this.linkedAcross(a, b),
|
|
4708
4773
|
unverified,
|
|
4709
4774
|
strict
|
|
4710
4775
|
});
|
|
@@ -5435,7 +5500,8 @@ ${stderr}` : "";
|
|
|
5435
5500
|
}
|
|
5436
5501
|
placeView(row, shown) {
|
|
5437
5502
|
const parent = row.parent_ep && (!shown || shown.has(row.parent_ep)) ? this.byId(row.parent_ep) : void 0;
|
|
5438
|
-
|
|
5503
|
+
const across = !parent && row.parent_ep === null && row.parent_addr ? this.linkedParent(row, shown) : null;
|
|
5504
|
+
return { parent: parent ? this.address(parent) : across, depth: row.depth ?? 0, scope: presetOf(this.scopeNode(row), this.defaults().symmetric) };
|
|
5439
5505
|
}
|
|
5440
5506
|
/** `recipient` names the delivery's endpoint when there is no `seq`: a send to several addresses shows each recipient only itself */
|
|
5441
5507
|
envelope(row, seq, recipient) {
|
package/package.json
CHANGED
package/src/core.ts
CHANGED
|
@@ -243,6 +243,8 @@ export interface EndpointRow {
|
|
|
243
243
|
/** the root endpoint's id; null for a root */
|
|
244
244
|
tree: string | null
|
|
245
245
|
parent_ep: string | null
|
|
246
|
+
/** a parent on another machine of the account, `<machine>/<handle>`: the row stays a root of its own tree */
|
|
247
|
+
parent_addr: string | null
|
|
246
248
|
depth: number | null
|
|
247
249
|
/** JSON {@link ScopeRules} */
|
|
248
250
|
rules: string | null
|
|
@@ -533,6 +535,7 @@ export class RelayCore {
|
|
|
533
535
|
for (const [column, type] of [
|
|
534
536
|
["tree", "TEXT"],
|
|
535
537
|
["parent_ep", "TEXT"],
|
|
538
|
+
["parent_addr", "TEXT"],
|
|
536
539
|
["depth", "INTEGER"],
|
|
537
540
|
["rules", "TEXT"],
|
|
538
541
|
["caps", "TEXT"],
|
|
@@ -1203,7 +1206,7 @@ export class RelayCore {
|
|
|
1203
1206
|
}
|
|
1204
1207
|
caps.caps.push(...this.capsOf(row).caps)
|
|
1205
1208
|
this.sql.all(
|
|
1206
|
-
`UPDATE endpoints SET tree = ?, parent_ep = ?, depth = ?, rules = ?, caps = ? WHERE id = ?`,
|
|
1209
|
+
`UPDATE endpoints SET tree = ?, parent_ep = ?, parent_addr = NULL, depth = ?, rules = ?, caps = ? WHERE id = ?`,
|
|
1207
1210
|
parent ? (parent.tree ?? parent.id) : null,
|
|
1208
1211
|
parent?.id ?? null,
|
|
1209
1212
|
depth,
|
|
@@ -1500,13 +1503,21 @@ export class RelayCore {
|
|
|
1500
1503
|
*/
|
|
1501
1504
|
waker(endpoint: string, live: boolean, options: { grace?: boolean } = {}): void {
|
|
1502
1505
|
if (live) this.sql.all(`UPDATE endpoints SET wakers = wakers + 1 WHERE id = ?`, endpoint)
|
|
1503
|
-
else
|
|
1506
|
+
else {
|
|
1507
|
+
const now = this.now()
|
|
1504
1508
|
this.sql.all(
|
|
1505
1509
|
`UPDATE endpoints SET wakers = MAX(wakers - 1, 0), waker_left = CASE WHEN ? THEN ? ELSE waker_left END WHERE id = ?`,
|
|
1506
1510
|
options.grace ? 1 : 0,
|
|
1507
|
-
|
|
1511
|
+
now,
|
|
1508
1512
|
endpoint,
|
|
1509
1513
|
)
|
|
1514
|
+
if (!options.grace) this.lapseWake(endpoint, now)
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/** clears a wake no waker holds once a hook waker's grace ran out too: nothing is left to wake the endpoint */
|
|
1519
|
+
private lapseWake(endpoint: string, now: number): void {
|
|
1520
|
+
this.sql.all(`UPDATE endpoints SET wake = NULL WHERE id = ? AND wakers = 0 AND COALESCE(waker_left, 0) < ?`, endpoint, now - LIMITS.restartGraceS * 1000)
|
|
1510
1521
|
}
|
|
1511
1522
|
|
|
1512
1523
|
/**
|
|
@@ -1620,7 +1631,8 @@ export class RelayCore {
|
|
|
1620
1631
|
const target = params.endpoint === undefined ? me : this.resolve(params.endpoint)
|
|
1621
1632
|
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent unlinks only itself")
|
|
1622
1633
|
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be unlinked")
|
|
1623
|
-
if (target.parent_ep === null
|
|
1634
|
+
if (target.parent_ep === null && target.parent_addr === null)
|
|
1635
|
+
throw new RelayError("invalid_params", `${this.address(target)} is a root: it has no parent`)
|
|
1624
1636
|
if (actor.kind !== "human" && this.policy?.defaults?.unlink !== "self")
|
|
1625
1637
|
throw this.scopeRefused(
|
|
1626
1638
|
actor,
|
|
@@ -1628,6 +1640,14 @@ export class RelayCore {
|
|
|
1628
1640
|
{ target: this.address(target), reason: "unlink" },
|
|
1629
1641
|
"unlinking widens a worker's scope: only the human at a terminal unlinks it (policy defaults.unlink: self lets it)",
|
|
1630
1642
|
)
|
|
1643
|
+
if (target.parent_ep === null) {
|
|
1644
|
+
const from = this.linkedParent(target) ?? target.parent_addr!
|
|
1645
|
+
this.sql.all(`UPDATE endpoints SET parent_addr = NULL, linked = NULL WHERE id = ?`, target.id)
|
|
1646
|
+
this.scopeMemo?.ancestors.clear()
|
|
1647
|
+
this.scopeMemo?.limited.clear()
|
|
1648
|
+
this.audit("unlink", target.id, null, { ...this.actorDetail(actor, me), target: this.address(target), parent: from })
|
|
1649
|
+
return { endpoint: this.whoami(target.id), parent: from }
|
|
1650
|
+
}
|
|
1631
1651
|
const parent = this.byId(target.parent_ep)
|
|
1632
1652
|
const from = parent ? this.address(parent) : target.parent_ep
|
|
1633
1653
|
const shift = target.depth ?? 0
|
|
@@ -1652,16 +1672,26 @@ export class RelayCore {
|
|
|
1652
1672
|
})
|
|
1653
1673
|
}
|
|
1654
1674
|
|
|
1655
|
-
/**
|
|
1675
|
+
/**
|
|
1676
|
+
* `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants; a parent on another
|
|
1677
|
+
* machine goes to {@link linkAcross}
|
|
1678
|
+
*/
|
|
1656
1679
|
private link(row: EndpointRow, parent: EndpointRow, by: { by: string; auth: string }): void {
|
|
1657
|
-
if (
|
|
1680
|
+
if (!this.localRow(parent)) {
|
|
1681
|
+
const key = this.linkAcross(row, parent)
|
|
1682
|
+
if (row.parent_addr === key) return
|
|
1683
|
+
if (row.parent_ep !== null || row.parent_addr !== null) throw this.alreadyLinked(row)
|
|
1684
|
+
this.sql.all(`UPDATE endpoints SET parent_addr = ?, linked = ? WHERE id = ?`, key, this.now(), row.id)
|
|
1685
|
+
this.scopeMemo?.ancestors.clear()
|
|
1686
|
+
this.scopeMemo?.limited.clear()
|
|
1687
|
+
this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) })
|
|
1688
|
+
return
|
|
1689
|
+
}
|
|
1690
|
+
if (parent.kind !== "agent") throw new RelayError("invalid_params", "the parent is an agent endpoint")
|
|
1658
1691
|
if (row.parent_ep === parent.id) return
|
|
1659
1692
|
if (parent.id === row.id || this.ancestorsOf(parent).includes(row.id))
|
|
1660
1693
|
throw new RelayError("scope_refused", `${this.address(parent)} sits under ${this.address(row)}: a session cannot go under its own descendant`)
|
|
1661
|
-
if (row.parent_ep !== null)
|
|
1662
|
-
const now = this.byId(row.parent_ep)
|
|
1663
|
-
throw new RelayError("scope_refused", `${this.address(row)} already sits under ${now ? this.address(now) : "another agent"}; unlink it first`)
|
|
1664
|
-
}
|
|
1694
|
+
if (row.parent_ep !== null || row.parent_addr !== null) throw this.alreadyLinked(row)
|
|
1665
1695
|
const depth = (parent.depth ?? 0) + 1
|
|
1666
1696
|
const deepest = one<{ d: number | null }>(this.sql, `SELECT MAX(depth) AS d FROM endpoints WHERE tree = ?`, row.id)?.d ?? 0
|
|
1667
1697
|
if (deepest + depth > LIMITS.depthMax)
|
|
@@ -1686,6 +1716,31 @@ export class RelayCore {
|
|
|
1686
1716
|
this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) })
|
|
1687
1717
|
}
|
|
1688
1718
|
|
|
1719
|
+
/** a link of `row`, which already sits under a parent, to another one */
|
|
1720
|
+
private alreadyLinked(row: EndpointRow): RelayError {
|
|
1721
|
+
const local = row.parent_ep ? this.byId(row.parent_ep) : undefined
|
|
1722
|
+
const now = local ? this.address(local) : row.parent_addr !== null ? (this.linkedParent(row) ?? row.parent_addr) : "another agent"
|
|
1723
|
+
return new RelayError("scope_refused", `${this.address(row)} already sits under ${now}; unlink it first`)
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/**
|
|
1727
|
+
* the key `<machine>/<handle>` that links `row`, a local agent, under `parent`, an agent on another machine, once the parent is checked; no
|
|
1728
|
+
* inherited or child-default caps come with it. A relay of one machine has no such parent
|
|
1729
|
+
*/
|
|
1730
|
+
protected linkAcross(_row: EndpointRow, _parent: EndpointRow): string {
|
|
1731
|
+
throw new RelayError("invalid_params", "the parent is a local agent endpoint")
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
/** whether `a` and `b` are a parent and its child on different machines */
|
|
1735
|
+
protected linkedAcross(_a: EndpointRow, _b: EndpointRow): boolean {
|
|
1736
|
+
return false
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
/** the address of `row`'s parent on another machine, null when it has none; with `shown`, null for a parent outside it */
|
|
1740
|
+
protected linkedParent(_row: EndpointRow, _shown?: Set<string>): string | null {
|
|
1741
|
+
return null
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1689
1744
|
/**
|
|
1690
1745
|
* renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
|
|
1691
1746
|
* the policy's `defaults.rename` is `self`; the human at a terminal renames any local agent. The new handle is valid and held by no other endpoint
|
|
@@ -1734,9 +1789,13 @@ export class RelayCore {
|
|
|
1734
1789
|
] as const)
|
|
1735
1790
|
this.sql.all(`UPDATE ${table} SET ${column} = REPLACE(${column}, ?, ?) WHERE instr(${column}, ?) > 0`, a, b, a)
|
|
1736
1791
|
}
|
|
1792
|
+
this.renamedHandle(from, handle)
|
|
1737
1793
|
this.audit("rename", row.id, null, { from, to: handle })
|
|
1738
1794
|
}
|
|
1739
1795
|
|
|
1796
|
+
/** a local agent's handle moved from `from` to `to`: a subclass updates what names it on another machine's rows */
|
|
1797
|
+
protected renamedHandle(_from: string, _to: string): void {}
|
|
1798
|
+
|
|
1740
1799
|
/**
|
|
1741
1800
|
* a session leaves: it lets go of its session and token and shows offline, its mail kept; a later join takes it back. An agent leaves only
|
|
1742
1801
|
* itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
|
|
@@ -3044,15 +3103,24 @@ export class RelayCore {
|
|
|
3044
3103
|
}
|
|
3045
3104
|
}
|
|
3046
3105
|
|
|
3047
|
-
/** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
|
|
3106
|
+
/** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would, and lapses wakes whose hook waker never came back */
|
|
3048
3107
|
endExited(now = this.now()): void {
|
|
3049
3108
|
for (const row of this.sql.all<EndpointRow>(
|
|
3050
3109
|
`SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND hook_seen IS NOT NULL AND process IS NOT NULL`,
|
|
3051
3110
|
)) {
|
|
3052
3111
|
const pid = Number(/^pid:(\d+)/.exec(row.process!)?.[1])
|
|
3053
3112
|
if (!pid || !this.localRow(row) || this.alive(pid)) continue
|
|
3054
|
-
this.sql.all(
|
|
3113
|
+
this.sql.all(
|
|
3114
|
+
`UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = NULL, wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ? AND connections = 0`,
|
|
3115
|
+
now,
|
|
3116
|
+
row.id,
|
|
3117
|
+
)
|
|
3055
3118
|
}
|
|
3119
|
+
for (const row of this.sql.all<EndpointRow>(
|
|
3120
|
+
`SELECT * FROM endpoints WHERE wake IS NOT NULL AND wakers = 0 AND waker_left < ?`,
|
|
3121
|
+
now - LIMITS.restartGraceS * 1000,
|
|
3122
|
+
))
|
|
3123
|
+
if (this.localRow(row)) this.lapseWake(row.id, now)
|
|
3056
3124
|
}
|
|
3057
3125
|
|
|
3058
3126
|
/** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
|
|
@@ -3109,7 +3177,8 @@ export class RelayCore {
|
|
|
3109
3177
|
if (params.kind === "session_end") {
|
|
3110
3178
|
if (own)
|
|
3111
3179
|
this.sql.all(
|
|
3112
|
-
`UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END
|
|
3180
|
+
`UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END,
|
|
3181
|
+
wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ?`,
|
|
3113
3182
|
now,
|
|
3114
3183
|
endpoint,
|
|
3115
3184
|
)
|
|
@@ -3821,7 +3890,8 @@ export class RelayCore {
|
|
|
3821
3890
|
}
|
|
3822
3891
|
const note = [`exited ${p.code}`, spender.status_note].filter(Boolean).join("; ").slice(0, LIMITS.noteChars)
|
|
3823
3892
|
this.sql.all(
|
|
3824
|
-
`UPDATE endpoints SET status_state = 'done', status_note = ?, hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END
|
|
3893
|
+
`UPDATE endpoints SET status_state = 'done', status_note = ?, hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END,
|
|
3894
|
+
wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ?`,
|
|
3825
3895
|
note,
|
|
3826
3896
|
now,
|
|
3827
3897
|
spender.id,
|
|
@@ -4310,6 +4380,7 @@ export class RelayCore {
|
|
|
4310
4380
|
return scopeDecision(world, world.node(a.id) ?? this.scopeNode(a), world.node(b.id) ?? this.scopeNode(b), {
|
|
4311
4381
|
humanAlways: this.defaults().humanAlways,
|
|
4312
4382
|
solicited,
|
|
4383
|
+
linked: a.id !== b.id && this.linkedAcross(a, b),
|
|
4313
4384
|
unverified,
|
|
4314
4385
|
strict,
|
|
4315
4386
|
})
|
|
@@ -5139,7 +5210,8 @@ export class RelayCore {
|
|
|
5139
5210
|
|
|
5140
5211
|
private placeView(row: EndpointRow, shown?: Set<string>): Pick<EndpointView, "parent" | "depth" | "scope"> {
|
|
5141
5212
|
const parent = row.parent_ep && (!shown || shown.has(row.parent_ep)) ? this.byId(row.parent_ep) : undefined
|
|
5142
|
-
|
|
5213
|
+
const across = !parent && row.parent_ep === null && row.parent_addr ? this.linkedParent(row, shown) : null
|
|
5214
|
+
return { parent: parent ? this.address(parent) : across, depth: row.depth ?? 0, scope: presetOf(this.scopeNode(row), this.defaults().symmetric) }
|
|
5143
5215
|
}
|
|
5144
5216
|
|
|
5145
5217
|
/** `recipient` names the delivery's endpoint when there is no `seq`: a send to several addresses shows each recipient only itself */
|
package/src/scopes.ts
CHANGED
|
@@ -293,19 +293,20 @@ export function relationText(peer: PolicySubject, me: PolicySubject): string {
|
|
|
293
293
|
/**
|
|
294
294
|
* The scope check for a message from `a` to `b`: `out(a)` admits `b` and `in(b)` admits `a`. Either side the account's human (with `humanAlways`)
|
|
295
295
|
* or the system endpoint passes, except an `unverified` human sender (one without a terminal), which is checked like any unplaced sender; an
|
|
296
|
-
* ancestor of `b` (unless `strict`)
|
|
296
|
+
* ancestor of `b` (unless `strict`), a solicited reply and a parent and child `linked` across machines pass both.
|
|
297
297
|
*/
|
|
298
298
|
export function scopeDecision(
|
|
299
299
|
world: ScopeWorld,
|
|
300
300
|
a: ScopeNode,
|
|
301
301
|
b: ScopeNode,
|
|
302
|
-
options: { humanAlways: boolean; solicited?: boolean; unverified?: boolean; strict?: boolean },
|
|
302
|
+
options: { humanAlways: boolean; solicited?: boolean; linked?: boolean; unverified?: boolean; strict?: boolean },
|
|
303
303
|
): ScopeDecision {
|
|
304
304
|
const kinds = [a.subject.kind, b.subject.kind]
|
|
305
305
|
if (kinds.includes("system")) return { allowed: true, reasons: ["the relay itself"] }
|
|
306
306
|
if (options.humanAlways && (b.human || (a.human && !options.unverified))) return { allowed: true, reasons: ["the human is always reachable"] }
|
|
307
307
|
if (!options.strict && isAncestor(a, b)) return { allowed: true, reasons: [`${a.address} is an ancestor of ${b.address}: scopes never stop it`] }
|
|
308
308
|
if (options.solicited) return { allowed: true, reasons: [`a reply to ${b.address}'s message in its thread`] }
|
|
309
|
+
if (options.linked) return { allowed: true, reasons: [`${a.address} and ${b.address} are parent and child across machines`] }
|
|
309
310
|
const memo: Memo = new Map()
|
|
310
311
|
const out = admits(world, a, "out", b, memo)
|
|
311
312
|
if (!out.ok) return { allowed: false, dir: "out", reasons: [out.why] }
|