@scp-dev/core 0.1.2 → 0.1.3
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 +32 -2
- package/dist/index.js +119 -6
- package/package.json +1 -1
- package/src/core.ts +118 -4
- package/src/policy.ts +7 -1
package/dist/index.d.ts
CHANGED
|
@@ -811,6 +811,8 @@ interface PolicyDefaults {
|
|
|
811
811
|
verify_lineage?: "spike" | boolean;
|
|
812
812
|
/** who may rename an agent besides the human at a terminal: `root` (default) only a root session itself, `self` any agent itself, spawned ones too */
|
|
813
813
|
rename?: "root" | "self";
|
|
814
|
+
/** who may unlink an agent from its parent besides the human at a terminal: `human` (default) nobody, `self` the agent itself */
|
|
815
|
+
unlink?: "human" | "self";
|
|
814
816
|
}
|
|
815
817
|
interface Policy {
|
|
816
818
|
rules: PolicyRule[];
|
|
@@ -1088,6 +1090,8 @@ interface EndpointRow {
|
|
|
1088
1090
|
peek: PeekLevel | null;
|
|
1089
1091
|
/** when the session last set its handle itself (join or rename): a later claim with another handle does not rename it back */
|
|
1090
1092
|
named: number | null;
|
|
1093
|
+
/** when the session itself or the human linked it under a parent by hand: a later root claim of it takes it back where it sits */
|
|
1094
|
+
linked: number | null;
|
|
1091
1095
|
}
|
|
1092
1096
|
interface MessageRow {
|
|
1093
1097
|
id: string;
|
|
@@ -1317,7 +1321,33 @@ declare class RelayCore {
|
|
|
1317
1321
|
* a session joins on demand: its hook claim registers or takes back its endpoint, marked seen now even right after a leave, and `handle`,
|
|
1318
1322
|
* when given, renames it in place ({@link rename}'s rules). Nothing is kept when the rename is refused
|
|
1319
1323
|
*/
|
|
1320
|
-
joinSession(claim: Claim, handle?: unknown): EndpointView;
|
|
1324
|
+
joinSession(claim: Claim, handle?: unknown, under?: unknown): EndpointView;
|
|
1325
|
+
/**
|
|
1326
|
+
* links a local root agent under `parent`, as a spawned worker sits: in the parent's tree one level down, under the policy's child scope.
|
|
1327
|
+
* Only the human at a terminal links another session, or an agent from its own shell with a terminal attached, under itself by default.
|
|
1328
|
+
* A session links itself with {@link joinSession}'s `under`
|
|
1329
|
+
*/
|
|
1330
|
+
adopt(actor: ScopeActor, params: {
|
|
1331
|
+
endpoint: unknown;
|
|
1332
|
+
parent?: unknown;
|
|
1333
|
+
}, options?: {
|
|
1334
|
+
tty?: boolean;
|
|
1335
|
+
}): {
|
|
1336
|
+
endpoint: EndpointView;
|
|
1337
|
+
parent: string;
|
|
1338
|
+
};
|
|
1339
|
+
/**
|
|
1340
|
+
* a linked agent back to a root: its subtree moves along and the policy's child scope comes off. The human at a terminal unlinks any local
|
|
1341
|
+
* agent; an agent only itself, and only when the policy's `defaults.unlink` is `self`, since a worker detaching widens its own scope
|
|
1342
|
+
*/
|
|
1343
|
+
unlink(actor: ScopeActor, params?: {
|
|
1344
|
+
endpoint?: unknown;
|
|
1345
|
+
}): {
|
|
1346
|
+
endpoint: EndpointView;
|
|
1347
|
+
parent: string;
|
|
1348
|
+
};
|
|
1349
|
+
/** `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants */
|
|
1350
|
+
private link;
|
|
1321
1351
|
/**
|
|
1322
1352
|
* renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
|
|
1323
1353
|
* 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
|
|
@@ -1334,7 +1364,7 @@ declare class RelayCore {
|
|
|
1334
1364
|
private renameRow;
|
|
1335
1365
|
/**
|
|
1336
1366
|
* 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
|
|
1337
|
-
* itself, the human at a terminal any local root. A spawned worker stays joined while it runs
|
|
1367
|
+
* itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
|
|
1338
1368
|
*/
|
|
1339
1369
|
leaveSession(actor: ScopeActor, params?: {
|
|
1340
1370
|
endpoint?: unknown;
|
package/dist/index.js
CHANGED
|
@@ -701,7 +701,7 @@ function parsePolicy(value, options = {}) {
|
|
|
701
701
|
}
|
|
702
702
|
if (defaults !== void 0) {
|
|
703
703
|
if (!isObject2(defaults)) throw new RelayError("invalid_params", "defaults is an object");
|
|
704
|
-
const { human_always, symmetric_presets, child_default, verify_lineage, rename, ...extra } = defaults;
|
|
704
|
+
const { human_always, symmetric_presets, child_default, verify_lineage, rename, unlink, ...extra } = defaults;
|
|
705
705
|
if (Object.keys(extra).length) throw new RelayError("invalid_params", `unknown defaults: ${Object.keys(extra).join(", ")}`);
|
|
706
706
|
const parsed = {};
|
|
707
707
|
for (const [key, flag] of [
|
|
@@ -729,6 +729,10 @@ function parsePolicy(value, options = {}) {
|
|
|
729
729
|
if (rename !== "root" && rename !== "self") throw new RelayError("invalid_params", "defaults.rename is root or self");
|
|
730
730
|
parsed.rename = rename;
|
|
731
731
|
}
|
|
732
|
+
if (unlink !== void 0) {
|
|
733
|
+
if (unlink !== "human" && unlink !== "self") throw new RelayError("invalid_params", "defaults.unlink is human or self");
|
|
734
|
+
parsed.unlink = unlink;
|
|
735
|
+
}
|
|
732
736
|
policy.defaults = parsed;
|
|
733
737
|
}
|
|
734
738
|
if (judge !== void 0) policy.judge = parseJudgePolicy(judge, options.judgeProviders);
|
|
@@ -1315,7 +1319,8 @@ var RelayCore = class {
|
|
|
1315
1319
|
["title_auto", "TEXT"],
|
|
1316
1320
|
["title_turn", "INTEGER"],
|
|
1317
1321
|
["tldr_turn", "INTEGER"],
|
|
1318
|
-
["named", "INTEGER"]
|
|
1322
|
+
["named", "INTEGER"],
|
|
1323
|
+
["linked", "INTEGER"]
|
|
1319
1324
|
]) {
|
|
1320
1325
|
if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`);
|
|
1321
1326
|
}
|
|
@@ -1845,7 +1850,7 @@ var RelayCore = class {
|
|
|
1845
1850
|
*/
|
|
1846
1851
|
restarts(row, parent, process) {
|
|
1847
1852
|
if (parent?.id === row.id) return process !== null && row.process !== null && sameHarness(row.process, process);
|
|
1848
|
-
return (parent?.id ?? null) === row.parent_ep;
|
|
1853
|
+
return (parent?.id ?? null) === row.parent_ep || parent === null && row.linked !== null;
|
|
1849
1854
|
}
|
|
1850
1855
|
/**
|
|
1851
1856
|
* whether the claim re-claims `row` with the grant `row` spent: from `row`'s own process or one of its ancestors, or with no lineage from
|
|
@@ -2129,16 +2134,124 @@ var RelayCore = class {
|
|
|
2129
2134
|
* a session joins on demand: its hook claim registers or takes back its endpoint, marked seen now even right after a leave, and `handle`,
|
|
2130
2135
|
* when given, renames it in place ({@link rename}'s rules). Nothing is kept when the rename is refused
|
|
2131
2136
|
*/
|
|
2132
|
-
joinSession(claim, handle) {
|
|
2137
|
+
joinSession(claim, handle, under) {
|
|
2133
2138
|
return this.scopeTx(() => {
|
|
2134
2139
|
const { endpoint } = this.claim(claim, { transient: true });
|
|
2135
2140
|
const row = this.mustEndpoint(endpoint.id);
|
|
2136
2141
|
if (handle !== void 0 && handle !== null && handle !== "") this.renameRow(row, handle, false);
|
|
2137
2142
|
this.sql.all(`UPDATE endpoints SET hook_seen = ?, hook_ended = NULL WHERE id = ?`, this.now(), row.id);
|
|
2138
2143
|
this.audit("join", row.id, null, { handle: this.byId(row.id).handle });
|
|
2144
|
+
if (under !== void 0 && under !== null && under !== "") {
|
|
2145
|
+
const me = this.byId(row.id);
|
|
2146
|
+
const parent = this.resolve(under);
|
|
2147
|
+
if (parent.id !== me.parent_ep && parent.kind === "agent" && !this.mayMessage(me, parent)) throw unreachable(under);
|
|
2148
|
+
this.link(me, parent, { by: this.address(me), auth: "session" });
|
|
2149
|
+
}
|
|
2139
2150
|
return this.whoami(row.id);
|
|
2140
2151
|
});
|
|
2141
2152
|
}
|
|
2153
|
+
/**
|
|
2154
|
+
* links a local root agent under `parent`, as a spawned worker sits: in the parent's tree one level down, under the policy's child scope.
|
|
2155
|
+
* Only the human at a terminal links another session, or an agent from its own shell with a terminal attached, under itself by default.
|
|
2156
|
+
* A session links itself with {@link joinSession}'s `under`
|
|
2157
|
+
*/
|
|
2158
|
+
adopt(actor, params, options = {}) {
|
|
2159
|
+
return this.scopeTx(() => {
|
|
2160
|
+
const me = this.actorRow(actor);
|
|
2161
|
+
if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint);
|
|
2162
|
+
else if (options.tty !== true)
|
|
2163
|
+
throw this.scopeRefused(
|
|
2164
|
+
actor,
|
|
2165
|
+
me,
|
|
2166
|
+
{ target: String(params.endpoint), reason: "adopt" },
|
|
2167
|
+
`only the human at a terminal links another session under a parent; the session links itself with ${CLI_NAME} join --under`
|
|
2168
|
+
);
|
|
2169
|
+
const target = this.resolve(params.endpoint);
|
|
2170
|
+
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be linked");
|
|
2171
|
+
const parentAddress = params.parent ?? (actor.kind === "human" ? void 0 : this.address(me));
|
|
2172
|
+
if (parentAddress === void 0 || parentAddress === null || parentAddress === "")
|
|
2173
|
+
throw new RelayError("invalid_params", "the human names the parent to link under");
|
|
2174
|
+
const parent = this.resolve(parentAddress);
|
|
2175
|
+
this.link(target, parent, this.actorDetail(actor, me));
|
|
2176
|
+
return { endpoint: this.whoami(target.id), parent: this.address(parent) };
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
2179
|
+
/**
|
|
2180
|
+
* a linked agent back to a root: its subtree moves along and the policy's child scope comes off. The human at a terminal unlinks any local
|
|
2181
|
+
* agent; an agent only itself, and only when the policy's `defaults.unlink` is `self`, since a worker detaching widens its own scope
|
|
2182
|
+
*/
|
|
2183
|
+
unlink(actor, params = {}) {
|
|
2184
|
+
return this.scopeTx(() => {
|
|
2185
|
+
const me = this.actorRow(actor);
|
|
2186
|
+
if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint);
|
|
2187
|
+
else if (actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself unlinks it: run this from the session's own shell");
|
|
2188
|
+
const target = params.endpoint === void 0 ? me : this.resolve(params.endpoint);
|
|
2189
|
+
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent unlinks only itself");
|
|
2190
|
+
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be unlinked");
|
|
2191
|
+
if (target.parent_ep === null) throw new RelayError("invalid_params", `${this.address(target)} is a root: it has no parent`);
|
|
2192
|
+
if (actor.kind !== "human" && this.policy?.defaults?.unlink !== "self")
|
|
2193
|
+
throw this.scopeRefused(
|
|
2194
|
+
actor,
|
|
2195
|
+
me,
|
|
2196
|
+
{ target: this.address(target), reason: "unlink" },
|
|
2197
|
+
"unlinking widens a worker's scope: only the human at a terminal unlinks it (policy defaults.unlink: self lets it)"
|
|
2198
|
+
);
|
|
2199
|
+
const parent = this.byId(target.parent_ep);
|
|
2200
|
+
const from = parent ? this.address(parent) : target.parent_ep;
|
|
2201
|
+
const shift = target.depth ?? 0;
|
|
2202
|
+
this.sql.all(
|
|
2203
|
+
`WITH RECURSIVE below(id) AS (SELECT id FROM endpoints WHERE parent_ep = ? UNION SELECT e.id FROM endpoints e JOIN below b ON e.parent_ep = b.id)
|
|
2204
|
+
UPDATE endpoints SET tree = ?, depth = depth - ? WHERE id IN (SELECT id FROM below)`,
|
|
2205
|
+
target.id,
|
|
2206
|
+
target.id,
|
|
2207
|
+
shift
|
|
2208
|
+
);
|
|
2209
|
+
const caps = this.capsOf(target);
|
|
2210
|
+
const kept = caps.caps.filter((cap) => cap.by !== "policy");
|
|
2211
|
+
this.sql.all(
|
|
2212
|
+
`UPDATE endpoints SET tree = NULL, parent_ep = NULL, depth = 0, linked = NULL, caps = ? WHERE id = ?`,
|
|
2213
|
+
kept.length || Object.keys(caps.allow).length ? JSON.stringify({ ...caps, caps: kept }) : null,
|
|
2214
|
+
target.id
|
|
2215
|
+
);
|
|
2216
|
+
this.scopeMemo?.ancestors.clear();
|
|
2217
|
+
this.scopeMemo?.limited.clear();
|
|
2218
|
+
this.audit("unlink", target.id, null, { ...this.actorDetail(actor, me), target: this.address(target), parent: from });
|
|
2219
|
+
return { endpoint: this.whoami(target.id), parent: from };
|
|
2220
|
+
});
|
|
2221
|
+
}
|
|
2222
|
+
/** `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants */
|
|
2223
|
+
link(row, parent, by) {
|
|
2224
|
+
if (parent.kind !== "agent" || !this.localRow(parent)) throw new RelayError("invalid_params", "the parent is a local agent endpoint");
|
|
2225
|
+
if (row.parent_ep === parent.id) return;
|
|
2226
|
+
if (parent.id === row.id || this.ancestorsOf(parent).includes(row.id))
|
|
2227
|
+
throw new RelayError("scope_refused", `${this.address(parent)} sits under ${this.address(row)}: a session cannot go under its own descendant`);
|
|
2228
|
+
if (row.parent_ep !== null) {
|
|
2229
|
+
const now = this.byId(row.parent_ep);
|
|
2230
|
+
throw new RelayError("scope_refused", `${this.address(row)} already sits under ${now ? this.address(now) : "another agent"}; unlink it first`);
|
|
2231
|
+
}
|
|
2232
|
+
const depth = (parent.depth ?? 0) + 1;
|
|
2233
|
+
const deepest = one(this.sql, `SELECT MAX(depth) AS d FROM endpoints WHERE tree = ?`, row.id)?.d ?? 0;
|
|
2234
|
+
if (deepest + depth > LIMITS.depthMax)
|
|
2235
|
+
throw new RelayError("scope_refused", `${this.address(row)} would sit deeper than ${LIMITS.depthMax} under ${this.address(parent)}`);
|
|
2236
|
+
const tree = parent.tree ?? parent.id;
|
|
2237
|
+
this.sql.all(`UPDATE endpoints SET tree = ?, depth = depth + ? WHERE tree = ?`, tree, depth, row.id);
|
|
2238
|
+
const caps = this.capsOf(row);
|
|
2239
|
+
const childDefault = this.defaults().childDefault;
|
|
2240
|
+
if (childDefault !== "open" && childDefault !== "inherit" && !caps.caps.some((cap) => cap.by === "policy"))
|
|
2241
|
+
for (const dir of this.presetDirs()) caps.caps.push({ id: capId(), dir, by: "policy", sels: PRESET_LISTS[childDefault].map((sel) => ({ sel })) });
|
|
2242
|
+
this.sql.all(
|
|
2243
|
+
`UPDATE endpoints SET tree = ?, parent_ep = ?, depth = ?, linked = ?, caps = ? WHERE id = ?`,
|
|
2244
|
+
tree,
|
|
2245
|
+
parent.id,
|
|
2246
|
+
depth,
|
|
2247
|
+
this.now(),
|
|
2248
|
+
caps.caps.length || Object.keys(caps.allow).length ? JSON.stringify(caps) : null,
|
|
2249
|
+
row.id
|
|
2250
|
+
);
|
|
2251
|
+
this.scopeMemo?.ancestors.clear();
|
|
2252
|
+
this.scopeMemo?.limited.clear();
|
|
2253
|
+
this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) });
|
|
2254
|
+
}
|
|
2142
2255
|
/**
|
|
2143
2256
|
* renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
|
|
2144
2257
|
* 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
|
|
@@ -2190,7 +2303,7 @@ var RelayCore = class {
|
|
|
2190
2303
|
}
|
|
2191
2304
|
/**
|
|
2192
2305
|
* 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
|
|
2193
|
-
* itself, the human at a terminal any local root. A spawned worker stays joined while it runs
|
|
2306
|
+
* itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
|
|
2194
2307
|
*/
|
|
2195
2308
|
leaveSession(actor, params = {}) {
|
|
2196
2309
|
return this.scopeTx(() => {
|
|
@@ -2200,7 +2313,7 @@ var RelayCore = class {
|
|
|
2200
2313
|
const target = params.endpoint === void 0 ? me : this.resolve(params.endpoint);
|
|
2201
2314
|
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent leaves only for itself");
|
|
2202
2315
|
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can leave");
|
|
2203
|
-
if (target.parent_ep !== null) {
|
|
2316
|
+
if (target.parent_ep !== null && target.linked === null) {
|
|
2204
2317
|
const parent = this.byId(target.parent_ep);
|
|
2205
2318
|
throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; a spawned worker stays joined while it runs`);
|
|
2206
2319
|
}
|
package/package.json
CHANGED
package/src/core.ts
CHANGED
|
@@ -256,6 +256,8 @@ export interface EndpointRow {
|
|
|
256
256
|
peek: PeekLevel | null
|
|
257
257
|
/** when the session last set its handle itself (join or rename): a later claim with another handle does not rename it back */
|
|
258
258
|
named: number | null
|
|
259
|
+
/** when the session itself or the human linked it under a parent by hand: a later root claim of it takes it back where it sits */
|
|
260
|
+
linked: number | null
|
|
259
261
|
}
|
|
260
262
|
|
|
261
263
|
export interface MessageRow {
|
|
@@ -520,6 +522,7 @@ export class RelayCore {
|
|
|
520
522
|
["title_turn", "INTEGER"],
|
|
521
523
|
["tldr_turn", "INTEGER"],
|
|
522
524
|
["named", "INTEGER"],
|
|
525
|
+
["linked", "INTEGER"],
|
|
523
526
|
] as const) {
|
|
524
527
|
if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`)
|
|
525
528
|
}
|
|
@@ -1111,7 +1114,7 @@ export class RelayCore {
|
|
|
1111
1114
|
*/
|
|
1112
1115
|
private restarts(row: EndpointRow, parent: EndpointRow | null, process: string | null): boolean {
|
|
1113
1116
|
if (parent?.id === row.id) return process !== null && row.process !== null && sameHarness(row.process, process)
|
|
1114
|
-
return (parent?.id ?? null) === row.parent_ep
|
|
1117
|
+
return (parent?.id ?? null) === row.parent_ep || (parent === null && row.linked !== null)
|
|
1115
1118
|
}
|
|
1116
1119
|
|
|
1117
1120
|
/**
|
|
@@ -1444,17 +1447,128 @@ export class RelayCore {
|
|
|
1444
1447
|
* a session joins on demand: its hook claim registers or takes back its endpoint, marked seen now even right after a leave, and `handle`,
|
|
1445
1448
|
* when given, renames it in place ({@link rename}'s rules). Nothing is kept when the rename is refused
|
|
1446
1449
|
*/
|
|
1447
|
-
joinSession(claim: Claim, handle?: unknown): EndpointView {
|
|
1450
|
+
joinSession(claim: Claim, handle?: unknown, under?: unknown): EndpointView {
|
|
1448
1451
|
return this.scopeTx(() => {
|
|
1449
1452
|
const { endpoint } = this.claim(claim, { transient: true })
|
|
1450
1453
|
const row = this.mustEndpoint(endpoint.id)
|
|
1451
1454
|
if (handle !== undefined && handle !== null && handle !== "") this.renameRow(row, handle, false)
|
|
1452
1455
|
this.sql.all(`UPDATE endpoints SET hook_seen = ?, hook_ended = NULL WHERE id = ?`, this.now(), row.id)
|
|
1453
1456
|
this.audit("join", row.id, null, { handle: this.byId(row.id)!.handle })
|
|
1457
|
+
if (under !== undefined && under !== null && under !== "") {
|
|
1458
|
+
const me = this.byId(row.id)!
|
|
1459
|
+
const parent = this.resolve(under)
|
|
1460
|
+
if (parent.id !== me.parent_ep && parent.kind === "agent" && !this.mayMessage(me, parent)) throw unreachable(under)
|
|
1461
|
+
this.link(me, parent, { by: this.address(me), auth: "session" })
|
|
1462
|
+
}
|
|
1454
1463
|
return this.whoami(row.id)
|
|
1455
1464
|
})
|
|
1456
1465
|
}
|
|
1457
1466
|
|
|
1467
|
+
/**
|
|
1468
|
+
* links a local root agent under `parent`, as a spawned worker sits: in the parent's tree one level down, under the policy's child scope.
|
|
1469
|
+
* Only the human at a terminal links another session, or an agent from its own shell with a terminal attached, under itself by default.
|
|
1470
|
+
* A session links itself with {@link joinSession}'s `under`
|
|
1471
|
+
*/
|
|
1472
|
+
adopt(actor: ScopeActor, params: { endpoint: unknown; parent?: unknown }, options: { tty?: boolean } = {}): { endpoint: EndpointView; parent: string } {
|
|
1473
|
+
return this.scopeTx(() => {
|
|
1474
|
+
const me = this.actorRow(actor)
|
|
1475
|
+
if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint)
|
|
1476
|
+
else if (options.tty !== true)
|
|
1477
|
+
throw this.scopeRefused(
|
|
1478
|
+
actor,
|
|
1479
|
+
me,
|
|
1480
|
+
{ target: String(params.endpoint), reason: "adopt" },
|
|
1481
|
+
`only the human at a terminal links another session under a parent; the session links itself with ${CLI_NAME} join --under`,
|
|
1482
|
+
)
|
|
1483
|
+
const target = this.resolve(params.endpoint)
|
|
1484
|
+
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be linked")
|
|
1485
|
+
const parentAddress = params.parent ?? (actor.kind === "human" ? undefined : this.address(me))
|
|
1486
|
+
if (parentAddress === undefined || parentAddress === null || parentAddress === "")
|
|
1487
|
+
throw new RelayError("invalid_params", "the human names the parent to link under")
|
|
1488
|
+
const parent = this.resolve(parentAddress)
|
|
1489
|
+
this.link(target, parent, this.actorDetail(actor, me))
|
|
1490
|
+
return { endpoint: this.whoami(target.id), parent: this.address(parent) }
|
|
1491
|
+
})
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* a linked agent back to a root: its subtree moves along and the policy's child scope comes off. The human at a terminal unlinks any local
|
|
1496
|
+
* agent; an agent only itself, and only when the policy's `defaults.unlink` is `self`, since a worker detaching widens its own scope
|
|
1497
|
+
*/
|
|
1498
|
+
unlink(actor: ScopeActor, params: { endpoint?: unknown } = {}): { endpoint: EndpointView; parent: string } {
|
|
1499
|
+
return this.scopeTx(() => {
|
|
1500
|
+
const me = this.actorRow(actor)
|
|
1501
|
+
if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint)
|
|
1502
|
+
else if (actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself unlinks it: run this from the session's own shell")
|
|
1503
|
+
const target = params.endpoint === undefined ? me : this.resolve(params.endpoint)
|
|
1504
|
+
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent unlinks only itself")
|
|
1505
|
+
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be unlinked")
|
|
1506
|
+
if (target.parent_ep === null) throw new RelayError("invalid_params", `${this.address(target)} is a root: it has no parent`)
|
|
1507
|
+
if (actor.kind !== "human" && this.policy?.defaults?.unlink !== "self")
|
|
1508
|
+
throw this.scopeRefused(
|
|
1509
|
+
actor,
|
|
1510
|
+
me,
|
|
1511
|
+
{ target: this.address(target), reason: "unlink" },
|
|
1512
|
+
"unlinking widens a worker's scope: only the human at a terminal unlinks it (policy defaults.unlink: self lets it)",
|
|
1513
|
+
)
|
|
1514
|
+
const parent = this.byId(target.parent_ep)
|
|
1515
|
+
const from = parent ? this.address(parent) : target.parent_ep
|
|
1516
|
+
const shift = target.depth ?? 0
|
|
1517
|
+
this.sql.all(
|
|
1518
|
+
`WITH RECURSIVE below(id) AS (SELECT id FROM endpoints WHERE parent_ep = ? UNION SELECT e.id FROM endpoints e JOIN below b ON e.parent_ep = b.id)
|
|
1519
|
+
UPDATE endpoints SET tree = ?, depth = depth - ? WHERE id IN (SELECT id FROM below)`,
|
|
1520
|
+
target.id,
|
|
1521
|
+
target.id,
|
|
1522
|
+
shift,
|
|
1523
|
+
)
|
|
1524
|
+
const caps = this.capsOf(target)
|
|
1525
|
+
const kept = caps.caps.filter((cap) => cap.by !== "policy")
|
|
1526
|
+
this.sql.all(
|
|
1527
|
+
`UPDATE endpoints SET tree = NULL, parent_ep = NULL, depth = 0, linked = NULL, caps = ? WHERE id = ?`,
|
|
1528
|
+
kept.length || Object.keys(caps.allow).length ? JSON.stringify({ ...caps, caps: kept }) : null,
|
|
1529
|
+
target.id,
|
|
1530
|
+
)
|
|
1531
|
+
this.scopeMemo?.ancestors.clear()
|
|
1532
|
+
this.scopeMemo?.limited.clear()
|
|
1533
|
+
this.audit("unlink", target.id, null, { ...this.actorDetail(actor, me), target: this.address(target), parent: from })
|
|
1534
|
+
return { endpoint: this.whoami(target.id), parent: from }
|
|
1535
|
+
})
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/** `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants */
|
|
1539
|
+
private link(row: EndpointRow, parent: EndpointRow, by: { by: string; auth: string }): void {
|
|
1540
|
+
if (parent.kind !== "agent" || !this.localRow(parent)) throw new RelayError("invalid_params", "the parent is a local agent endpoint")
|
|
1541
|
+
if (row.parent_ep === parent.id) return
|
|
1542
|
+
if (parent.id === row.id || this.ancestorsOf(parent).includes(row.id))
|
|
1543
|
+
throw new RelayError("scope_refused", `${this.address(parent)} sits under ${this.address(row)}: a session cannot go under its own descendant`)
|
|
1544
|
+
if (row.parent_ep !== null) {
|
|
1545
|
+
const now = this.byId(row.parent_ep)
|
|
1546
|
+
throw new RelayError("scope_refused", `${this.address(row)} already sits under ${now ? this.address(now) : "another agent"}; unlink it first`)
|
|
1547
|
+
}
|
|
1548
|
+
const depth = (parent.depth ?? 0) + 1
|
|
1549
|
+
const deepest = one<{ d: number | null }>(this.sql, `SELECT MAX(depth) AS d FROM endpoints WHERE tree = ?`, row.id)?.d ?? 0
|
|
1550
|
+
if (deepest + depth > LIMITS.depthMax)
|
|
1551
|
+
throw new RelayError("scope_refused", `${this.address(row)} would sit deeper than ${LIMITS.depthMax} under ${this.address(parent)}`)
|
|
1552
|
+
const tree = parent.tree ?? parent.id
|
|
1553
|
+
this.sql.all(`UPDATE endpoints SET tree = ?, depth = depth + ? WHERE tree = ?`, tree, depth, row.id)
|
|
1554
|
+
const caps = this.capsOf(row)
|
|
1555
|
+
const childDefault = this.defaults().childDefault
|
|
1556
|
+
if (childDefault !== "open" && childDefault !== "inherit" && !caps.caps.some((cap) => cap.by === "policy"))
|
|
1557
|
+
for (const dir of this.presetDirs()) caps.caps.push({ id: capId(), dir, by: "policy", sels: PRESET_LISTS[childDefault].map((sel) => ({ sel })) })
|
|
1558
|
+
this.sql.all(
|
|
1559
|
+
`UPDATE endpoints SET tree = ?, parent_ep = ?, depth = ?, linked = ?, caps = ? WHERE id = ?`,
|
|
1560
|
+
tree,
|
|
1561
|
+
parent.id,
|
|
1562
|
+
depth,
|
|
1563
|
+
this.now(),
|
|
1564
|
+
caps.caps.length || Object.keys(caps.allow).length ? JSON.stringify(caps) : null,
|
|
1565
|
+
row.id,
|
|
1566
|
+
)
|
|
1567
|
+
this.scopeMemo?.ancestors.clear()
|
|
1568
|
+
this.scopeMemo?.limited.clear()
|
|
1569
|
+
this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) })
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1458
1572
|
/**
|
|
1459
1573
|
* renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
|
|
1460
1574
|
* 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
|
|
@@ -1508,7 +1622,7 @@ export class RelayCore {
|
|
|
1508
1622
|
|
|
1509
1623
|
/**
|
|
1510
1624
|
* 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
|
|
1511
|
-
* itself, the human at a terminal any local root. A spawned worker stays joined while it runs
|
|
1625
|
+
* itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
|
|
1512
1626
|
*/
|
|
1513
1627
|
leaveSession(actor: ScopeActor, params: { endpoint?: unknown } = {}): { endpoint: EndpointView; session: string | null } {
|
|
1514
1628
|
return this.scopeTx(() => {
|
|
@@ -1518,7 +1632,7 @@ export class RelayCore {
|
|
|
1518
1632
|
const target = params.endpoint === undefined ? me : this.resolve(params.endpoint)
|
|
1519
1633
|
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent leaves only for itself")
|
|
1520
1634
|
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can leave")
|
|
1521
|
-
if (target.parent_ep !== null) {
|
|
1635
|
+
if (target.parent_ep !== null && target.linked === null) {
|
|
1522
1636
|
const parent = this.byId(target.parent_ep)
|
|
1523
1637
|
throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; a spawned worker stays joined while it runs`)
|
|
1524
1638
|
}
|
package/src/policy.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface PolicyDefaults {
|
|
|
38
38
|
verify_lineage?: "spike" | boolean
|
|
39
39
|
/** who may rename an agent besides the human at a terminal: `root` (default) only a root session itself, `self` any agent itself, spawned ones too */
|
|
40
40
|
rename?: "root" | "self"
|
|
41
|
+
/** who may unlink an agent from its parent besides the human at a terminal: `human` (default) nobody, `self` the agent itself */
|
|
42
|
+
unlink?: "human" | "self"
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
export interface Policy {
|
|
@@ -166,7 +168,7 @@ export function parsePolicy(value: unknown, options: { judgeProviders?: Readonly
|
|
|
166
168
|
}
|
|
167
169
|
if (defaults !== undefined) {
|
|
168
170
|
if (!isObject(defaults)) throw new RelayError("invalid_params", "defaults is an object")
|
|
169
|
-
const { human_always, symmetric_presets, child_default, verify_lineage, rename, ...extra } = defaults
|
|
171
|
+
const { human_always, symmetric_presets, child_default, verify_lineage, rename, unlink, ...extra } = defaults
|
|
170
172
|
if (Object.keys(extra).length) throw new RelayError("invalid_params", `unknown defaults: ${Object.keys(extra).join(", ")}`)
|
|
171
173
|
const parsed: PolicyDefaults = {}
|
|
172
174
|
for (const [key, flag] of [
|
|
@@ -194,6 +196,10 @@ export function parsePolicy(value: unknown, options: { judgeProviders?: Readonly
|
|
|
194
196
|
if (rename !== "root" && rename !== "self") throw new RelayError("invalid_params", "defaults.rename is root or self")
|
|
195
197
|
parsed.rename = rename
|
|
196
198
|
}
|
|
199
|
+
if (unlink !== undefined) {
|
|
200
|
+
if (unlink !== "human" && unlink !== "self") throw new RelayError("invalid_params", "defaults.unlink is human or self")
|
|
201
|
+
parsed.unlink = unlink
|
|
202
|
+
}
|
|
197
203
|
policy.defaults = parsed
|
|
198
204
|
}
|
|
199
205
|
if (judge !== undefined) policy.judge = parseJudgePolicy(judge, options.judgeProviders)
|