@zgeoff/atc 2.15.1 → 2.16.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/package.json +2 -2
- package/src/agents/build-hook-settings.ts +12 -3
- package/src/agents/print-codex-hook-file.ts +1 -1
- package/src/agents/print-grok-hook-file.ts +1 -1
- package/src/cli.ts +15 -4
- package/src/daemon/broker-authority-error.ts +41 -0
- package/src/daemon/build-fleet-events.ts +10 -4
- package/src/daemon/build-grant-from-fleet-entry.ts +14 -0
- package/src/daemon/build-report-view.ts +10 -4
- package/src/daemon/build-scoped-context.ts +63 -76
- package/src/daemon/collect-secret-rule-mismatches.ts +83 -0
- package/src/daemon/daemon-connection.ts +202 -51
- package/src/daemon/daemon-context.ts +63 -12
- package/src/daemon/daemon.ts +258 -90
- package/src/daemon/drain-inbox.ts +85 -0
- package/src/daemon/imp-client-port.ts +75 -3
- package/src/daemon/imp-port.ts +60 -0
- package/src/daemon/is-imp-name-allowed.ts +38 -0
- package/src/daemon/is-own-hook-event.ts +19 -0
- package/src/daemon/is-tree-in-reach.ts +32 -0
- package/src/daemon/parse-hook-line.ts +3 -0
- package/src/daemon/session-runtime.ts +6 -0
- package/src/daemon/sessions.ts +62 -22
- package/src/daemon/tap-registry.ts +43 -5
- package/src/daemon/verify-broker-authority.ts +52 -0
- package/src/daemon/verify-cleanup-authority.ts +44 -0
- package/src/daemon/verify-token-imp-authority.ts +74 -0
- package/src/hook-report.ts +6 -4
- package/src/protocol/hook-event.ts +6 -2
- package/src/statusline.ts +8 -6
- package/src/store/run-migrations.ts +55 -4
- package/src/store/state-store.ts +44 -9
package/src/daemon/daemon.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { buildConfigRevision } from './build-config-revision';
|
|
|
33
33
|
import { buildExecutionTargets } from './build-execution-targets';
|
|
34
34
|
import type { ExecutionTarget } from './build-execution-targets';
|
|
35
35
|
import { buildFleetEvents } from './build-fleet-events';
|
|
36
|
+
import { buildGrantFromFleetEntry } from './build-grant-from-fleet-entry';
|
|
36
37
|
import { buildMessageTrailEntry } from './build-message-trail-entry';
|
|
37
38
|
import { buildReportTrailEntry } from './build-report-trail-entry';
|
|
38
39
|
import { buildReportView } from './build-report-view';
|
|
@@ -51,10 +52,14 @@ import type {
|
|
|
51
52
|
SpawnParams,
|
|
52
53
|
TapClient,
|
|
53
54
|
} from './daemon-context';
|
|
55
|
+
import { drainInbox } from './drain-inbox';
|
|
56
|
+
import type { InboxSource } from './drain-inbox';
|
|
54
57
|
import { EffectRemainsError } from './effect-remains-error';
|
|
55
58
|
import { EventSignal } from './event-signal';
|
|
56
59
|
import { startHookServer } from './hooks';
|
|
57
60
|
import { IdempotencyLedger } from './idempotency-ledger';
|
|
61
|
+
import { isOwnHookEvent } from './is-own-hook-event';
|
|
62
|
+
import { isTreeInReach } from './is-tree-in-reach';
|
|
58
63
|
import { loadTranscriptPage } from './load-transcript-page';
|
|
59
64
|
import { makeHookRunner } from './make-hook-runner';
|
|
60
65
|
import type { HookScope } from './make-hook-runner';
|
|
@@ -321,8 +326,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
321
326
|
|
|
322
327
|
// A live session's scope also matches the rows it wrote under an earlier
|
|
323
328
|
// atc id, through the agent session id a restore carries on. Under an
|
|
324
|
-
// access, the scope holds only the
|
|
325
|
-
//
|
|
329
|
+
// access, the scope holds only the atc ids of the sessions whose whole
|
|
330
|
+
// tree is on targets the access holds, so a session outside it matches
|
|
331
|
+
// nothing, as a session never seen does. It holds no agent session id there: a session on
|
|
332
|
+
// another target can resume the same agent session, and its rows would
|
|
333
|
+
// match.
|
|
326
334
|
const buildEventScope = (
|
|
327
335
|
sessionID: SessionID | null,
|
|
328
336
|
access: TargetAccess | null,
|
|
@@ -341,19 +349,27 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
341
349
|
}
|
|
342
350
|
|
|
343
351
|
const visible = mgr.sessions.filter(
|
|
344
|
-
(x) =>
|
|
352
|
+
(x) =>
|
|
353
|
+
(sessionID === null || x.id === sessionID) && isTreeInReach(mgr.sessions, x.id, access),
|
|
345
354
|
);
|
|
346
355
|
|
|
347
|
-
return {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
356
|
+
return { atcIDs: visible.map((x) => x.id), agentSessionIDs: [] };
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// The sessions that may name a trail row: under an access, only those
|
|
360
|
+
// whose whole tree is on targets it holds.
|
|
361
|
+
const collectNamingDescriptors = (access: TargetAccess | null): SessionDescriptor[] => {
|
|
362
|
+
const reached = new Set(
|
|
363
|
+
mgr.sessions
|
|
364
|
+
.filter((x) => access === null || isTreeInReach(mgr.sessions, x.id, access))
|
|
365
|
+
.map((x) => x.id),
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
return mgr.collectDescriptors().filter((d) => reached.has(d.id));
|
|
353
369
|
};
|
|
354
370
|
|
|
355
|
-
// A message belongs to the live session holding its atc id
|
|
356
|
-
// session id, else to the atc id it was sent to.
|
|
371
|
+
// A message belongs to the live session holding its atc id, else to the
|
|
372
|
+
// one holding its agent session id, else to the atc id it was sent to.
|
|
357
373
|
const readMessageView = async (messageID: MessageID) => {
|
|
358
374
|
const record = await store.findMessageByID(messageID);
|
|
359
375
|
|
|
@@ -361,11 +377,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
361
377
|
return null;
|
|
362
378
|
}
|
|
363
379
|
|
|
364
|
-
const owner =
|
|
365
|
-
(x) =>
|
|
366
|
-
|
|
367
|
-
(record.agentSessionID !== undefined && x.agentSessionID === record.agentSessionID
|
|
368
|
-
|
|
380
|
+
const owner =
|
|
381
|
+
mgr.sessions.find((x) => x.id === record.atcID) ??
|
|
382
|
+
mgr.sessions.find(
|
|
383
|
+
(x) => record.agentSessionID !== undefined && x.agentSessionID === record.agentSessionID,
|
|
384
|
+
);
|
|
369
385
|
|
|
370
386
|
return {
|
|
371
387
|
session: owner?.id ?? record.atcID,
|
|
@@ -408,50 +424,17 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
408
424
|
// the order of their sent times, which the inbox drains by.
|
|
409
425
|
let lastMessageWrite: Promise<void> = Promise.resolve();
|
|
410
426
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const tap = taps.claimDelivery(sessionID, record.id);
|
|
416
|
-
|
|
417
|
-
if (tap === null) {
|
|
418
|
-
return false;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
tap.sendEvent({
|
|
422
|
-
v: PROTOCOL_V,
|
|
423
|
-
ev: 'InboxMessage',
|
|
424
|
-
s: sessionID,
|
|
425
|
-
message: record.id,
|
|
426
|
-
from: record.from,
|
|
427
|
-
text: record.text,
|
|
428
|
-
sentAt: record.sentAt,
|
|
429
|
-
});
|
|
427
|
+
const inboxSource: InboxSource = {
|
|
428
|
+
taps,
|
|
429
|
+
findLinkedOwner: (sessionID) => {
|
|
430
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
430
431
|
|
|
431
|
-
|
|
432
|
+
return s === undefined ? null : buildMessageOwner(s);
|
|
433
|
+
},
|
|
434
|
+
collectPendingMessages: (owner) => store.collectPendingMessages(owner),
|
|
432
435
|
};
|
|
433
436
|
|
|
434
|
-
|
|
435
|
-
// ok response is always queued ahead of its first message. It sends one
|
|
436
|
-
// unclaimed message per call and the tap's ack calls it again, so the
|
|
437
|
-
// backlog never outgrows the connection's outbound queue.
|
|
438
|
-
const drainInbox = async (sessionID: SessionID) => {
|
|
439
|
-
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
440
|
-
|
|
441
|
-
if (s === undefined) {
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
try {
|
|
446
|
-
const pending = await store.collectPendingMessages(buildMessageOwner(s));
|
|
447
|
-
|
|
448
|
-
for (const record of pending) {
|
|
449
|
-
if (sendInboxMessage(sessionID, record)) {
|
|
450
|
-
return;
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
} catch {}
|
|
454
|
-
};
|
|
437
|
+
const drainSessionInbox = (sessionID: SessionID) => drainInbox(sessionID, inboxSource);
|
|
455
438
|
|
|
456
439
|
// A report id, which a remote session's reporter gives each report,
|
|
457
440
|
// makes a resent note land once; a resent answer changes nothing, since
|
|
@@ -783,6 +766,22 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
783
766
|
}
|
|
784
767
|
|
|
785
768
|
const before = mgr.sessions.find((s) => s.id === e.atcId);
|
|
769
|
+
const runtime = runtimes.get(e.atcId);
|
|
770
|
+
|
|
771
|
+
// A harness nested inside a session inherits its environment and reports
|
|
772
|
+
// under its id; only the harness atc started may change the session.
|
|
773
|
+
if (
|
|
774
|
+
before !== undefined &&
|
|
775
|
+
runtime !== undefined &&
|
|
776
|
+
!isOwnHookEvent(e.agent, before.agent, runtime.hasAgentHookLines)
|
|
777
|
+
) {
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (runtime !== undefined && e.agent !== undefined) {
|
|
782
|
+
runtime.hasAgentHookLines = true;
|
|
783
|
+
}
|
|
784
|
+
|
|
786
785
|
const previousAgentSessionID = before?.agentSessionID;
|
|
787
786
|
const ev = mgr.applyHook(e);
|
|
788
787
|
|
|
@@ -795,7 +794,6 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
795
794
|
}
|
|
796
795
|
|
|
797
796
|
const kind = ev?.kind ?? null;
|
|
798
|
-
const runtime = runtimes.get(e.atcId);
|
|
799
797
|
const currentAgentSessionID = mgr.sessions.find((s) => s.id === e.atcId)?.agentSessionID;
|
|
800
798
|
|
|
801
799
|
if (currentAgentSessionID !== undefined && currentAgentSessionID !== previousAgentSessionID) {
|
|
@@ -841,8 +839,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
841
839
|
|
|
842
840
|
return true;
|
|
843
841
|
},
|
|
844
|
-
attachTap: (client, sessionID) => ctx.attachTap(client, sessionID),
|
|
845
|
-
ackMessage: (client, sessionID, messageID) =>
|
|
842
|
+
attachTap: (client, sessionID) => ctx.attachTap(client, sessionID, null),
|
|
843
|
+
ackMessage: (client, sessionID, messageID) =>
|
|
844
|
+
ctx.ackMessage(client, sessionID, messageID, null),
|
|
846
845
|
detachTap: (client) => {
|
|
847
846
|
taps.detachAll(client);
|
|
848
847
|
},
|
|
@@ -858,6 +857,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
858
857
|
const startSpawn = async (
|
|
859
858
|
p: SpawnParams,
|
|
860
859
|
id: SessionID,
|
|
860
|
+
requireInReach: () => void = () => {},
|
|
861
861
|
): Promise<Readonly<Record<string, unknown>>> => {
|
|
862
862
|
const prepared =
|
|
863
863
|
p.workspace === null ? null : await materializeSpawnWorkspace(p, id, p.workspace);
|
|
@@ -866,7 +866,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
866
866
|
const warnings = materialized === null ? [] : materialized.warnings;
|
|
867
867
|
|
|
868
868
|
try {
|
|
869
|
-
const session = await startSpawnedSession(p, id, materialized);
|
|
869
|
+
const session = await startSpawnedSession(p, id, materialized, requireInReach);
|
|
870
870
|
|
|
871
871
|
return warnings.length === 0 ? { session } : { session, warnings };
|
|
872
872
|
} catch (error) {
|
|
@@ -922,6 +922,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
922
922
|
workspace: SessionWorkspace;
|
|
923
923
|
withheldEnv: readonly string[];
|
|
924
924
|
}> | null,
|
|
925
|
+
requireInReach: () => void,
|
|
925
926
|
): Promise<SessionDescriptor> => {
|
|
926
927
|
const s = await mgr.spawn(
|
|
927
928
|
p.cwd,
|
|
@@ -937,6 +938,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
937
938
|
id,
|
|
938
939
|
p.target,
|
|
939
940
|
materialized,
|
|
941
|
+
requireInReach,
|
|
940
942
|
);
|
|
941
943
|
|
|
942
944
|
const runtime = runtimes.get(s.id);
|
|
@@ -945,7 +947,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
945
947
|
runtime.pendingLastUsed = true;
|
|
946
948
|
}
|
|
947
949
|
|
|
948
|
-
void store.recordSpawnDir(p.cwd);
|
|
950
|
+
void store.recordSpawnDir(p.cwd, { target: s.target, targetIdentity: s.targetIdentity });
|
|
949
951
|
|
|
950
952
|
return getDescriptor(mgr, s.id);
|
|
951
953
|
};
|
|
@@ -981,7 +983,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
981
983
|
// Throws the refusal a fresh spawn to the target gets when the access
|
|
982
984
|
// does not reach the target, and the identity, a held spawn key recorded
|
|
983
985
|
// for its session. A key that records none is refused: nothing it holds
|
|
984
|
-
// shows where its session ran.
|
|
986
|
+
// shows where its session ran. A session the daemon still holds is
|
|
987
|
+
// refused the same way when the access does not reach its whole tree.
|
|
985
988
|
const requireReplayInReach = (record: IdempotencyRecord, access: TargetAccess): void => {
|
|
986
989
|
const bound = record.effectTarget;
|
|
987
990
|
|
|
@@ -989,11 +992,48 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
989
992
|
throw buildTargetForbiddenError(findReplayTarget(record));
|
|
990
993
|
}
|
|
991
994
|
|
|
992
|
-
|
|
995
|
+
const session = mgr.sessions.find((x) => x.id === record.effectRef);
|
|
996
|
+
|
|
997
|
+
if (
|
|
998
|
+
!access.canUse(bound) ||
|
|
999
|
+
(session !== undefined && !isTreeInReach(mgr.sessions, session.id, access))
|
|
1000
|
+
) {
|
|
993
1001
|
throw buildTargetForbiddenError(bound.target);
|
|
994
1002
|
}
|
|
995
1003
|
};
|
|
996
1004
|
|
|
1005
|
+
// Throws the refusal of a replay out of reach when a held spawn key's
|
|
1006
|
+
// session is no longer live and the access does not reach the whole tree
|
|
1007
|
+
// its fleet rows hold, as the live check refuses a live tree. A session
|
|
1008
|
+
// with no fleet row has no tree left to check.
|
|
1009
|
+
const requireStoredTreeInReach = async (
|
|
1010
|
+
record: IdempotencyRecord,
|
|
1011
|
+
access: TargetAccess,
|
|
1012
|
+
): Promise<void> => {
|
|
1013
|
+
if (mgr.sessions.some((x) => x.id === record.effectRef)) {
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const fleet = await store.loadFleet();
|
|
1018
|
+
|
|
1019
|
+
const members = fleet.map((entry) => {
|
|
1020
|
+
const grant = buildGrantFromFleetEntry(entry);
|
|
1021
|
+
|
|
1022
|
+
return {
|
|
1023
|
+
id: entry.sessionID,
|
|
1024
|
+
parent: entry.parent ?? null,
|
|
1025
|
+
target: grant.target,
|
|
1026
|
+
targetIdentity: grant.targetIdentity,
|
|
1027
|
+
};
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
const own = members.find((member) => member.id === record.effectRef);
|
|
1031
|
+
|
|
1032
|
+
if (own !== undefined && !isTreeInReach(members, own.id, access)) {
|
|
1033
|
+
throw buildTargetForbiddenError(record.effectTarget?.target ?? findReplayTarget(record));
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
|
|
997
1037
|
// Each confirm token a forget handed out, by token: the session it
|
|
998
1038
|
// forgets, when it stops being taken, and whether a forget took it. A
|
|
999
1039
|
// token stays known for one more lifetime after it expires, so a late
|
|
@@ -1071,11 +1111,14 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1071
1111
|
// it to the session's tap when one is attached. Only the write itself can
|
|
1072
1112
|
// reject: every later step swallows its own failure, so a rejection means
|
|
1073
1113
|
// no message was written.
|
|
1114
|
+
// The caller's check runs again once the writes ahead of this one land,
|
|
1115
|
+
// before anything is written; a failed check writes nothing.
|
|
1074
1116
|
const writeAcceptedMessage = async (
|
|
1075
1117
|
sessionID: SessionID,
|
|
1076
1118
|
from: string,
|
|
1077
1119
|
text: string,
|
|
1078
1120
|
id: MessageID,
|
|
1121
|
+
requireWriteInReach: () => void,
|
|
1079
1122
|
): Promise<MessageRecord> => {
|
|
1080
1123
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
1081
1124
|
const previousWrite = lastMessageWrite;
|
|
@@ -1096,6 +1139,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1096
1139
|
};
|
|
1097
1140
|
|
|
1098
1141
|
try {
|
|
1142
|
+
// The write boundary: the reach check and the start of the row write
|
|
1143
|
+
// run in one synchronous step, so no wait lets the session's tree
|
|
1144
|
+
// leave the access between the check and the write it allows.
|
|
1145
|
+
requireWriteInReach();
|
|
1146
|
+
|
|
1099
1147
|
await store.writeMessage(record);
|
|
1100
1148
|
|
|
1101
1149
|
await recordMessageStatus(sessionID, record);
|
|
@@ -1103,7 +1151,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1103
1151
|
written.resolve();
|
|
1104
1152
|
}
|
|
1105
1153
|
|
|
1106
|
-
await
|
|
1154
|
+
await drainSessionInbox(sessionID);
|
|
1107
1155
|
|
|
1108
1156
|
return record;
|
|
1109
1157
|
};
|
|
@@ -1148,7 +1196,13 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1148
1196
|
build: opts.build,
|
|
1149
1197
|
daemonID: store.daemonID,
|
|
1150
1198
|
collectSessions: () => mgr.collectDescriptors(),
|
|
1151
|
-
collectSpawnDirs: () =>
|
|
1199
|
+
collectSpawnDirs: async (access) => {
|
|
1200
|
+
const dirs = await store.collectSpawnDirs();
|
|
1201
|
+
|
|
1202
|
+
const reached = dirs.filter((dir) => access === null || access.canUse(dir.grant));
|
|
1203
|
+
|
|
1204
|
+
return [...new Set(reached.map((dir) => dir.cwd))];
|
|
1205
|
+
},
|
|
1152
1206
|
collectAgents: () => ({
|
|
1153
1207
|
daemon: {
|
|
1154
1208
|
hostname: hostname(),
|
|
@@ -1172,8 +1226,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1172
1226
|
return s === undefined ? null : { target: s.target, targetIdentity: s.targetIdentity };
|
|
1173
1227
|
},
|
|
1174
1228
|
findTargetIdentity: (target) => targets.find((x) => x.id === target)?.identity ?? null,
|
|
1175
|
-
|
|
1229
|
+
canSeeSession: (id, access) => isTreeInReach(mgr.sessions, id, access),
|
|
1230
|
+
isSessionVisible: () => true,
|
|
1176
1231
|
findPermissionSession: (request) => registry.findSessionID(request),
|
|
1232
|
+
resolveSpawnParent: (id) => {
|
|
1233
|
+
const owner = mgr.sessions.find((x) => x.id === id);
|
|
1234
|
+
|
|
1235
|
+
if (owner === undefined) {
|
|
1236
|
+
return 'missing';
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
return owner.parent ?? owner.id;
|
|
1240
|
+
},
|
|
1177
1241
|
resolveSpawnTarget: (requested) => {
|
|
1178
1242
|
const target = requested ?? defaultTarget;
|
|
1179
1243
|
const refusal = mgr.findExecutionRefusal({ target, targetIdentity: null }, 'spawn');
|
|
@@ -1193,8 +1257,22 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1193
1257
|
mgr.requireExecution({ target, targetIdentity: null }, 'run');
|
|
1194
1258
|
},
|
|
1195
1259
|
spawnSession: (plan, keyed, access) => {
|
|
1260
|
+
// Under an access, a spawn under a parent whose tree leaves the access
|
|
1261
|
+
// before the harness starts is refused as a spawn under an unknown
|
|
1262
|
+
// parent.
|
|
1263
|
+
const startInReach = (p: SpawnParams, id: SessionID) =>
|
|
1264
|
+
startSpawn(p, id, () => {
|
|
1265
|
+
if (
|
|
1266
|
+
access !== null &&
|
|
1267
|
+
p.parent !== null &&
|
|
1268
|
+
!isTreeInReach(mgr.sessions, p.parent, access)
|
|
1269
|
+
) {
|
|
1270
|
+
throw new DaemonError('no_such_session', `no session '${p.parent}'`);
|
|
1271
|
+
}
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1196
1274
|
if (keyed === null) {
|
|
1197
|
-
return
|
|
1275
|
+
return startInReach(plan(), mintSessionID());
|
|
1198
1276
|
}
|
|
1199
1277
|
|
|
1200
1278
|
const effectRef = mintSessionID();
|
|
@@ -1203,13 +1281,21 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1203
1281
|
operation: 'session.spawn',
|
|
1204
1282
|
keyed,
|
|
1205
1283
|
effectRef,
|
|
1206
|
-
start: () =>
|
|
1284
|
+
start: () => startInReach(plan(), effectRef),
|
|
1207
1285
|
settle: () => mgr.writeFleet(),
|
|
1208
|
-
replay: (record) => {
|
|
1209
|
-
if (access
|
|
1210
|
-
|
|
1286
|
+
replay: async (record) => {
|
|
1287
|
+
if (access === null) {
|
|
1288
|
+
return loadSpawnReplay(record);
|
|
1211
1289
|
}
|
|
1212
1290
|
|
|
1291
|
+
requireReplayInReach(record, access);
|
|
1292
|
+
|
|
1293
|
+
await requireStoredTreeInReach(record, access);
|
|
1294
|
+
|
|
1295
|
+
// The tree may change while the fleet is read, so the live check
|
|
1296
|
+
// runs again in the step that answers.
|
|
1297
|
+
requireReplayInReach(record, access);
|
|
1298
|
+
|
|
1213
1299
|
return loadSpawnReplay(record);
|
|
1214
1300
|
},
|
|
1215
1301
|
findEffectTarget: () => {
|
|
@@ -1329,7 +1415,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1329
1415
|
|
|
1330
1416
|
return 'ok';
|
|
1331
1417
|
},
|
|
1332
|
-
adoptSession: async (id, cols, rows) => {
|
|
1418
|
+
adoptSession: async (id, cols, rows, access) => {
|
|
1333
1419
|
if (!hasResumableTranscript(mgr, id)) {
|
|
1334
1420
|
return 'no_transcript';
|
|
1335
1421
|
}
|
|
@@ -1338,7 +1424,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1338
1424
|
|
|
1339
1425
|
runtime?.stopHeadlessRun();
|
|
1340
1426
|
|
|
1341
|
-
const adopted = await mgr.adoptTerminal(
|
|
1427
|
+
const adopted = await mgr.adoptTerminal(
|
|
1428
|
+
id,
|
|
1429
|
+
cols,
|
|
1430
|
+
rows,
|
|
1431
|
+
() => access === null || isTreeInReach(mgr.sessions, id, access),
|
|
1432
|
+
);
|
|
1342
1433
|
|
|
1343
1434
|
if (adopted === null) {
|
|
1344
1435
|
return 'missing';
|
|
@@ -1496,7 +1587,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1496
1587
|
runtimes.get(sessionID)?.dims ?? { cols: 80, rows: 24 },
|
|
1497
1588
|
restoreFleet: (cols, rows) =>
|
|
1498
1589
|
restoreFleet({ mgr, store, findRuntime, cols, rows, capMs: opts.restoreBootTimeoutMs ?? 0 }),
|
|
1499
|
-
readSessionRecord: async (id) => {
|
|
1590
|
+
readSessionRecord: async (id, access) => {
|
|
1500
1591
|
const s = mgr.sessions.find((x) => x.id === id);
|
|
1501
1592
|
|
|
1502
1593
|
if (s === undefined) {
|
|
@@ -1511,7 +1602,19 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1511
1602
|
const result = s.result ?? null;
|
|
1512
1603
|
const createdAt = s.createdAt;
|
|
1513
1604
|
|
|
1514
|
-
|
|
1605
|
+
// The agent session id links rows from before atc ids stayed stable,
|
|
1606
|
+
// unless a session on another target resumed the same agent session,
|
|
1607
|
+
// whose rows it would match too. Under an access it links nothing: a
|
|
1608
|
+
// session that shared it may be gone, its rows still in the trail.
|
|
1609
|
+
const shared = mgr.sessions.some(
|
|
1610
|
+
(x) =>
|
|
1611
|
+
x.agentSessionID === s.agentSessionID &&
|
|
1612
|
+
(x.target !== s.target || x.targetIdentity !== s.targetIdentity),
|
|
1613
|
+
);
|
|
1614
|
+
|
|
1615
|
+
const linked = access !== null || shared ? undefined : s.agentSessionID;
|
|
1616
|
+
|
|
1617
|
+
const lastEventAt = await store.loadLastActivityAt(s.id, linked);
|
|
1515
1618
|
|
|
1516
1619
|
return {
|
|
1517
1620
|
session,
|
|
@@ -1547,13 +1650,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1547
1650
|
},
|
|
1548
1651
|
readEvents: async (afterID, limit, waitMs, sessionID, access) => {
|
|
1549
1652
|
const deadline = Date.now() + waitMs;
|
|
1550
|
-
const scope = buildEventScope(sessionID, access);
|
|
1551
1653
|
|
|
1552
1654
|
// A wake for an event the read leaves out (a heartbeat, or another
|
|
1553
1655
|
// session's event under a session filter) loops back to wait out the
|
|
1554
|
-
// rest of the window.
|
|
1656
|
+
// rest of the window. The scope is built again after every await, so
|
|
1657
|
+
// a session whose tree left the access while the read waited matches
|
|
1658
|
+
// nothing, and a read whose scope changed under its query runs again.
|
|
1555
1659
|
for (;;) {
|
|
1556
1660
|
const generation = eventSignal.generation;
|
|
1661
|
+
const scope = buildEventScope(sessionID, access);
|
|
1557
1662
|
|
|
1558
1663
|
// A read from a cursor takes one row past the limit to learn whether
|
|
1559
1664
|
// more follow; the latest events have nothing after them.
|
|
@@ -1562,11 +1667,24 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1562
1667
|
? await store.collectLatestEvents(limit, scope)
|
|
1563
1668
|
: await store.collectEventsAfter(afterID, limit + 1, scope);
|
|
1564
1669
|
|
|
1670
|
+
if (
|
|
1671
|
+
access !== null &&
|
|
1672
|
+
JSON.stringify(buildEventScope(sessionID, access)) !== JSON.stringify(scope)
|
|
1673
|
+
) {
|
|
1674
|
+
continue;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1565
1677
|
const remaining = deadline - Date.now();
|
|
1566
1678
|
|
|
1567
1679
|
if (rows.length > 0 || remaining <= 0 || eventSignal.disposed) {
|
|
1680
|
+
const naming = collectNamingDescriptors(access);
|
|
1681
|
+
|
|
1682
|
+
// Under an access an event takes no alias, so each event keeps the
|
|
1683
|
+
// atc id it was recorded under for the check that sends it.
|
|
1684
|
+
const aliases = access === null ? naming : [];
|
|
1685
|
+
|
|
1568
1686
|
return {
|
|
1569
|
-
events: buildFleetEvents(rows.slice(0, limit),
|
|
1687
|
+
events: buildFleetEvents(rows.slice(0, limit), naming, aliases),
|
|
1570
1688
|
more: rows.length > limit,
|
|
1571
1689
|
};
|
|
1572
1690
|
}
|
|
@@ -1577,9 +1695,30 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1577
1695
|
readReport: async (id, access) => {
|
|
1578
1696
|
const stored = await store.findReport(id, buildEventScope(null, access));
|
|
1579
1697
|
|
|
1580
|
-
|
|
1698
|
+
if (stored === null) {
|
|
1699
|
+
return null;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
const naming = collectNamingDescriptors(access);
|
|
1703
|
+
const aliases = access === null ? naming : [];
|
|
1704
|
+
|
|
1705
|
+
// Under an access a report takes no alias, so a session in reach
|
|
1706
|
+
// never names a report whose own session left the access while the
|
|
1707
|
+
// query waited.
|
|
1708
|
+
return {
|
|
1709
|
+
owner: stored.atcID,
|
|
1710
|
+
view: buildReportView(stored, naming, aliases),
|
|
1711
|
+
};
|
|
1581
1712
|
},
|
|
1582
|
-
writeSessionMessage: async (sessionID, from, text, keyed) => {
|
|
1713
|
+
writeSessionMessage: async (sessionID, from, text, keyed, access) => {
|
|
1714
|
+
// Under an access, a session whose tree leaves the access before the
|
|
1715
|
+
// message is written refuses it as an unknown session does.
|
|
1716
|
+
const requireInReach = () => {
|
|
1717
|
+
if (access !== null && !isTreeInReach(mgr.sessions, sessionID, access)) {
|
|
1718
|
+
throw new MessageRefusedError('missing');
|
|
1719
|
+
}
|
|
1720
|
+
};
|
|
1721
|
+
|
|
1583
1722
|
if (keyed === null) {
|
|
1584
1723
|
const refusal = findMessageRefusal(sessionID);
|
|
1585
1724
|
|
|
@@ -1587,9 +1726,23 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1587
1726
|
return refusal;
|
|
1588
1727
|
}
|
|
1589
1728
|
|
|
1590
|
-
|
|
1729
|
+
try {
|
|
1730
|
+
const record = await writeAcceptedMessage(
|
|
1731
|
+
sessionID,
|
|
1732
|
+
from,
|
|
1733
|
+
text,
|
|
1734
|
+
mintMessageID(),
|
|
1735
|
+
requireInReach,
|
|
1736
|
+
);
|
|
1737
|
+
|
|
1738
|
+
return { message: record.id, status: record.status };
|
|
1739
|
+
} catch (error) {
|
|
1740
|
+
if (error instanceof MessageRefusedError) {
|
|
1741
|
+
return error.refusal;
|
|
1742
|
+
}
|
|
1591
1743
|
|
|
1592
|
-
|
|
1744
|
+
throw error;
|
|
1745
|
+
}
|
|
1593
1746
|
}
|
|
1594
1747
|
|
|
1595
1748
|
const effectRef = mintMessageID();
|
|
@@ -1602,13 +1755,21 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1602
1755
|
|
|
1603
1756
|
// A refusal throws, so the claim drops and a retry runs fresh.
|
|
1604
1757
|
start: async () => {
|
|
1758
|
+
requireInReach();
|
|
1759
|
+
|
|
1605
1760
|
const refusal = findMessageRefusal(sessionID);
|
|
1606
1761
|
|
|
1607
1762
|
if (refusal !== null) {
|
|
1608
1763
|
throw new MessageRefusedError(refusal);
|
|
1609
1764
|
}
|
|
1610
1765
|
|
|
1611
|
-
const record = await writeAcceptedMessage(
|
|
1766
|
+
const record = await writeAcceptedMessage(
|
|
1767
|
+
sessionID,
|
|
1768
|
+
from,
|
|
1769
|
+
text,
|
|
1770
|
+
effectRef,
|
|
1771
|
+
requireInReach,
|
|
1772
|
+
);
|
|
1612
1773
|
|
|
1613
1774
|
return { message: record.id, status: record.status };
|
|
1614
1775
|
},
|
|
@@ -1625,7 +1786,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1625
1786
|
throw error;
|
|
1626
1787
|
}
|
|
1627
1788
|
},
|
|
1628
|
-
attachTap: (client, sessionID) => {
|
|
1789
|
+
attachTap: (client, sessionID, access) => {
|
|
1629
1790
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
1630
1791
|
|
|
1631
1792
|
if (s === undefined) {
|
|
@@ -1636,7 +1797,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1636
1797
|
return 'unsupported';
|
|
1637
1798
|
}
|
|
1638
1799
|
|
|
1639
|
-
emitInboxClosed(taps.attach(sessionID, client), sessionID, 'replaced');
|
|
1800
|
+
emitInboxClosed(taps.attach(sessionID, client, access === null), sessionID, 'replaced');
|
|
1640
1801
|
|
|
1641
1802
|
const runtime = runtimes.get(sessionID);
|
|
1642
1803
|
|
|
@@ -1644,10 +1805,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1644
1805
|
runtime.tapAttached = true;
|
|
1645
1806
|
}
|
|
1646
1807
|
|
|
1647
|
-
void
|
|
1808
|
+
void drainSessionInbox(sessionID);
|
|
1648
1809
|
|
|
1649
1810
|
return 'ok';
|
|
1650
1811
|
},
|
|
1812
|
+
detachTap: (client, sessionID) => {
|
|
1813
|
+
if (taps.isTap(sessionID, client)) {
|
|
1814
|
+
taps.removeSession(sessionID);
|
|
1815
|
+
}
|
|
1816
|
+
},
|
|
1651
1817
|
readMessage: async (messageID, waitMs) => {
|
|
1652
1818
|
const deadline = Date.now() + waitMs;
|
|
1653
1819
|
let initialStatus: MessageStatus | null = null;
|
|
@@ -1681,18 +1847,20 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1681
1847
|
await eventSignal.waitForNext(generation, remaining);
|
|
1682
1848
|
}
|
|
1683
1849
|
},
|
|
1684
|
-
ackMessage: async (client, sessionID, messageID) => {
|
|
1850
|
+
ackMessage: async (client, sessionID, messageID, access) => {
|
|
1685
1851
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
1686
1852
|
|
|
1687
1853
|
if (s === undefined || !taps.isTap(sessionID, client)) {
|
|
1688
1854
|
return 'not_tapping';
|
|
1689
1855
|
}
|
|
1690
1856
|
|
|
1691
|
-
|
|
1857
|
+
// Under an access, only a message sent to this atc id is the
|
|
1858
|
+
// session's to ack, as with the inbox drain.
|
|
1859
|
+
const owner = access === null ? buildMessageOwner(s) : { atcID: s.id };
|
|
1692
1860
|
|
|
1693
1861
|
const delivered = await store.updateMessageDelivered(messageID, owner, Date.now());
|
|
1694
1862
|
|
|
1695
|
-
void
|
|
1863
|
+
void drainSessionInbox(sessionID);
|
|
1696
1864
|
|
|
1697
1865
|
if (delivered !== null) {
|
|
1698
1866
|
await recordMessageStatus(sessionID, delivered);
|