@indigoai-us/hq-cli 5.123.2 → 5.125.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/CHANGELOG.md +47 -0
- package/dist/command-catalog.generated.d.ts +111 -1
- package/dist/command-catalog.generated.js +144 -1
- package/dist/commands/billing-sponsor.d.ts +83 -0
- package/dist/commands/billing-sponsor.js +344 -0
- package/dist/commands/billing.d.ts +3 -0
- package/dist/commands/billing.js +5 -0
- package/dist/commands/login.js +49 -6
- package/dist/commands/mesh.js +96 -19
- package/dist/lib/doctor/checks/work-context.js +4 -2
- package/dist/lib/mesh/live/backfill-held.d.ts +2 -0
- package/dist/lib/mesh/live/backfill-held.js +8 -4
- package/dist/lib/mesh/live/daemon/board-refresh.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/board-refresh.js +6 -0
- package/dist/lib/mesh/live/daemon/doctor.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/doctor.js +21 -1
- package/dist/lib/mesh/live/daemon/run.d.ts +11 -0
- package/dist/lib/mesh/live/daemon/run.js +129 -10
- package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
- package/dist/lib/mesh/live/flush.d.ts +3 -0
- package/dist/lib/mesh/live/flush.js +11 -0
- package/dist/lib/work-context/company.d.ts +8 -0
- package/dist/lib/work-context/company.js +62 -18
- package/dist/lib/work-context/config.d.ts +35 -0
- package/dist/lib/work-context/config.js +307 -62
- package/dist/lib/work-context/contract.d.ts +2 -0
- package/dist/lib/work-context/default-company.d.ts +43 -0
- package/dist/lib/work-context/default-company.js +83 -0
- package/dist/lib/work-context/outbox.d.ts +5 -1
- package/dist/lib/work-context/outbox.js +5 -0
- package/dist/lib/work-context/reconcile.d.ts +4 -0
- package/dist/lib/work-context/reconcile.js +59 -3
- package/dist/lib/work-context/state.d.ts +2 -0
- package/dist/lib/work-context/state.js +14 -0
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as os from "node:os";
|
|
8
8
|
import * as path from "node:path";
|
|
9
|
-
import { readDeviceConfig,
|
|
9
|
+
import { readDeviceConfig, } from "../../work-context/config.js";
|
|
10
10
|
import { outboxStats, listOutboxOperations } from "../../work-context/outbox.js";
|
|
11
11
|
import { workContextRoot, workContextSessionsDir } from "../../work-context/paths.js";
|
|
12
12
|
import { countJsonlLines, deadLetterNonEmpty, } from "../../mesh/live/spool.js";
|
|
@@ -96,7 +96,9 @@ function runChecks(deps) {
|
|
|
96
96
|
// Default-company mode + migration capability (US-017B)
|
|
97
97
|
try {
|
|
98
98
|
const cfg = readDeviceConfig({ root });
|
|
99
|
-
const def =
|
|
99
|
+
const def = cfg.defaultCompany?.enabled && cfg.defaultCompany.slug
|
|
100
|
+
? cfg.defaultCompany
|
|
101
|
+
: null;
|
|
100
102
|
const mode = def?.enabled
|
|
101
103
|
? `enabled slug=${def.slug}`
|
|
102
104
|
: "disabled (dark / unset)";
|
|
@@ -111,6 +111,8 @@ export interface BackfillHeldDeps {
|
|
|
111
111
|
* meta) in BOTH the dry-run predictor and the write path, so the two agree.
|
|
112
112
|
*/
|
|
113
113
|
remoteOwnerHint?: string;
|
|
114
|
+
/** Historical device-default repair is opt-in; false keeps no-evidence sessions held. */
|
|
115
|
+
useDeviceDefault?: boolean;
|
|
114
116
|
/** Env passed to the dry-run resolver (tests). Default process.env. */
|
|
115
117
|
env?: NodeJS.ProcessEnv;
|
|
116
118
|
/**
|
|
@@ -231,7 +231,7 @@ export function observationFromHeldRef(ref, contractVersion, clientOperationId)
|
|
|
231
231
|
* the write run can still under-count; the gross all-or-nothing mismatch is
|
|
232
232
|
* gone because both paths key off this same resolver + evidence.
|
|
233
233
|
*/
|
|
234
|
-
function defaultPredictCompany(ref, workContextRoot, remoteOwnerHint, env) {
|
|
234
|
+
function defaultPredictCompany(ref, workContextRoot, remoteOwnerHint, env, useDeviceDefault) {
|
|
235
235
|
// This predictor is called only with a ref already rebuilt from trusted local
|
|
236
236
|
// evidence. Do not let raw held-event cwd/hqRoot values re-enter resolution.
|
|
237
237
|
const remoteOwnerSlug = remoteOwnerHint?.trim();
|
|
@@ -241,6 +241,7 @@ function defaultPredictCompany(ref, workContextRoot, remoteOwnerHint, env) {
|
|
|
241
241
|
env,
|
|
242
242
|
trusted: ref.companySlug ? { companySlug: ref.companySlug } : undefined,
|
|
243
243
|
remoteOwnerSlug,
|
|
244
|
+
allowDeviceDefault: useDeviceDefault,
|
|
244
245
|
});
|
|
245
246
|
return resolution.status === "resolved";
|
|
246
247
|
}
|
|
@@ -385,7 +386,7 @@ export async function backfillHeldSessions(deps) {
|
|
|
385
386
|
const env = deps.env ?? process.env;
|
|
386
387
|
const newOperationId = deps.newOperationId ?? (() => globalThis.crypto.randomUUID());
|
|
387
388
|
const predictCompany = deps.predictCompany ??
|
|
388
|
-
((ref) => defaultPredictCompany(ref, deps.workContextRoot, deps.remoteOwnerHint, env));
|
|
389
|
+
((ref) => defaultPredictCompany(ref, deps.workContextRoot, deps.remoteOwnerHint, env, deps.useDeviceDefault === true));
|
|
389
390
|
const sessions = readHeldSessions(deps.workMeshRoot);
|
|
390
391
|
// An acked register is authoritative local evidence. Older clients could
|
|
391
392
|
// lose its state projection on a later hook write; recover that projection
|
|
@@ -434,7 +435,10 @@ export async function backfillHeldSessions(deps) {
|
|
|
434
435
|
continue;
|
|
435
436
|
}
|
|
436
437
|
}
|
|
437
|
-
|
|
438
|
+
// Historical repair may use the explicitly opted-in device default only
|
|
439
|
+
// when there is no durable local company evidence. Never manufacture a
|
|
440
|
+
// slug from the held event itself.
|
|
441
|
+
if (!evidence.candidate?.slug && deps.useDeviceDefault !== true) {
|
|
438
442
|
result.unresolved += 1;
|
|
439
443
|
deps.log?.(`would stay unresolved ${sessionId} (${hasOnlyStaleEventEvidence(ref, state) ? "STALE_EVIDENCE" : "no local company evidence"})`);
|
|
440
444
|
continue;
|
|
@@ -442,7 +446,7 @@ export async function backfillHeldSessions(deps) {
|
|
|
442
446
|
const evidenceRef = {
|
|
443
447
|
sessionId,
|
|
444
448
|
harness,
|
|
445
|
-
companySlug: evidence.candidate
|
|
449
|
+
companySlug: evidence.candidate?.slug,
|
|
446
450
|
// Held-event fields are transport input, not trusted context. Preserve
|
|
447
451
|
// project/task only when the durable session state already owns them.
|
|
448
452
|
project: state?.projectId,
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* authoritative in ProjectView because `hq mesh story` writes there.
|
|
7
7
|
* Metadata-only snapshot — never prompts or tokens.
|
|
8
8
|
*/
|
|
9
|
+
import type { SessionStateFile } from "../../../work-context/state.js";
|
|
9
10
|
export declare const BOARD_REFRESH_INTERVAL_MS = 60000;
|
|
10
11
|
export interface BoardStorySnapshot {
|
|
11
12
|
id: string;
|
|
@@ -26,6 +27,8 @@ export interface BoardRefreshDeps {
|
|
|
26
27
|
/** Per-session last refresh timestamps (ms). */
|
|
27
28
|
lastRefresh?: Map<string, number>;
|
|
28
29
|
intervalMs?: number;
|
|
30
|
+
/** Authorizes a device-default-derived session before its tenant read. */
|
|
31
|
+
authorizeDeviceDefaultTenant?: (sessionId: string, state: SessionStateFile) => Promise<boolean>;
|
|
29
32
|
}
|
|
30
33
|
export declare function formatBoardMarkdown(input: {
|
|
31
34
|
sessionId: string;
|
|
@@ -92,6 +92,12 @@ export async function refreshBoundSessionBoards(deps) {
|
|
|
92
92
|
skipped += 1;
|
|
93
93
|
continue;
|
|
94
94
|
}
|
|
95
|
+
if (state.companyConfidence === "device_default" &&
|
|
96
|
+
deps.authorizeDeviceDefaultTenant &&
|
|
97
|
+
!(await deps.authorizeDeviceDefaultTenant(sessionId, state))) {
|
|
98
|
+
skipped += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
95
101
|
try {
|
|
96
102
|
const board = await deps.readBoard(state.companyUid, state.projectId);
|
|
97
103
|
if (!board) {
|
|
@@ -19,6 +19,7 @@ export interface DaemonDoctorReport {
|
|
|
19
19
|
heldCount: number;
|
|
20
20
|
/** Distinct held sessions with neither bound state nor local company evidence. */
|
|
21
21
|
heldWithNoLocalEvidence: number;
|
|
22
|
+
attributedByDeviceDefault: number;
|
|
22
23
|
deadLetterCount: number;
|
|
23
24
|
/** Events in any local queue still marked no-company / NEEDS_COMPANY / NONE. */
|
|
24
25
|
unattributedEvents: UnattributedEventCounts;
|
|
@@ -47,6 +48,8 @@ export interface DaemonDoctorReport {
|
|
|
47
48
|
rejected?: number;
|
|
48
49
|
error?: string;
|
|
49
50
|
};
|
|
51
|
+
/** Work held or quarantined after default-derived membership was denied. */
|
|
52
|
+
deviceDefaultAuthorizationDenials: number;
|
|
50
53
|
}
|
|
51
54
|
export interface DaemonDoctorDeps {
|
|
52
55
|
home?: string;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import * as fs from "node:fs";
|
|
6
6
|
import * as os from "node:os";
|
|
7
|
+
import * as path from "node:path";
|
|
7
8
|
import { describeCognitoTokenSource, } from "../../../../utils/cognito-session.js";
|
|
8
9
|
import { listOutboxOperations, outboxStats, } from "../../../work-context/outbox.js";
|
|
9
10
|
import { readSessionState } from "../../../work-context/state.js";
|
|
@@ -52,6 +53,21 @@ function oldestJsonlAgeMs(filePath, nowMs) {
|
|
|
52
53
|
return undefined;
|
|
53
54
|
}
|
|
54
55
|
}
|
|
56
|
+
function countDeviceDefaultAttribution(root) {
|
|
57
|
+
try {
|
|
58
|
+
return fs.readdirSync(path.join(root, "sessions")).filter((entry) => {
|
|
59
|
+
if (!entry.endsWith(".json"))
|
|
60
|
+
return false;
|
|
61
|
+
const state = readSessionState(entry.slice(0, -5), root);
|
|
62
|
+
return state?.companyConfidence === "device_default" &&
|
|
63
|
+
Boolean(state.companyUid) &&
|
|
64
|
+
state.contextStatus !== "needs_company";
|
|
65
|
+
}).length;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
55
71
|
export function collectDaemonDoctor(deps = {}) {
|
|
56
72
|
const home = deps.home ?? os.homedir();
|
|
57
73
|
const env = deps.env ?? process.env;
|
|
@@ -88,6 +104,7 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
88
104
|
!hasCurrentAcknowledgedRegistration(ref, state, outboxOperations, deps.hqRoot ?? env.HQ_ROOT?.trim(), trustedRoots) &&
|
|
89
105
|
!refWithLocalCompanyEvidence(ref, deps.hqRoot ?? env.HQ_ROOT?.trim(), trustedRoots, state));
|
|
90
106
|
}).length;
|
|
107
|
+
const attributedByDeviceDefault = countDeviceDefaultAttribution(ctxRoot);
|
|
91
108
|
const deadLetterCount = countJsonlLines(deadPath);
|
|
92
109
|
const unattributedEvents = countUnattributedEvents(meshRoot);
|
|
93
110
|
const outbox = outboxStats(ctxRoot);
|
|
@@ -95,6 +112,7 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
95
112
|
const companiesOnline = state?.companiesOnline ?? [];
|
|
96
113
|
const emitMode = state?.emitMode ?? "legacy";
|
|
97
114
|
const subscribed = mqttState === "connected";
|
|
115
|
+
const deviceDefaultAuthorizationDenials = state?.deviceDefaultAuthorizationDenials ?? 0;
|
|
98
116
|
const oldestSpoolAgeMs = oldestJsonlAgeMs(spoolPath, now());
|
|
99
117
|
const unhealthyReasons = [];
|
|
100
118
|
const online = mqttState === "connected" && companiesOnline.length > 0;
|
|
@@ -140,6 +158,7 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
140
158
|
spoolDepth,
|
|
141
159
|
heldCount,
|
|
142
160
|
heldWithNoLocalEvidence,
|
|
161
|
+
attributedByDeviceDefault,
|
|
143
162
|
deadLetterCount,
|
|
144
163
|
unattributedEvents,
|
|
145
164
|
outboxDepth: outbox.depth,
|
|
@@ -164,6 +183,7 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
164
183
|
error: emitState.lastError,
|
|
165
184
|
}
|
|
166
185
|
: undefined,
|
|
186
|
+
deviceDefaultAuthorizationDenials,
|
|
167
187
|
};
|
|
168
188
|
}
|
|
169
189
|
export function formatDaemonDoctor(report) {
|
|
@@ -179,7 +199,7 @@ export function formatDaemonDoctor(report) {
|
|
|
179
199
|
// receive-only (direct) mode.
|
|
180
200
|
lines.push(`spool depth: ${report.spoolDepth}`, `held: ${report.heldCount}`, `held with no local evidence: ${report.heldWithNoLocalEvidence}`, `outbox depth: ${report.outboxDepth}`);
|
|
181
201
|
}
|
|
182
|
-
lines.push(`dead-letter: ${report.deadLetterCount}`, `unattributed events: ${report.unattributedEvents.total} ` +
|
|
202
|
+
lines.push(`attributed by device default: ${report.attributedByDeviceDefault}`, `device-default authorization denials: ${report.deviceDefaultAuthorizationDenials}`, `dead-letter: ${report.deadLetterCount}`, `unattributed events: ${report.unattributedEvents.total} ` +
|
|
183
203
|
`(spool=${report.unattributedEvents.spool} held=${report.unattributedEvents.held} dead-letter=${report.unattributedEvents.deadLetter})`, `token source: ${report.tokenSource}`, `actor kind: ${report.actorKind}`);
|
|
184
204
|
if (report.emitMode === "legacy") {
|
|
185
205
|
lines.push(`last flush: ${report.lastFlushAt ?? "(never)"}${report.lastFlushResult
|
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
* - MQTT presence (contract-3)
|
|
9
9
|
* - SIGTERM/SIGINT: flush once more, MQTT DISCONNECT (server derives offline), exit
|
|
10
10
|
*/
|
|
11
|
+
import { type DeliverFn } from "../../../work-context/outbox.js";
|
|
12
|
+
import { type SessionEventsPoster } from "../session-events-client.js";
|
|
11
13
|
import { type FlushSummary } from "../flush.js";
|
|
12
14
|
import { type BackfillHeldResult } from "../backfill-held.js";
|
|
15
|
+
import { type BoardReader } from "./board-refresh.js";
|
|
13
16
|
import { type CredentialsFetcher, type TimerHost } from "./credentials.js";
|
|
14
17
|
import { type PidLockDeps } from "./pid-lock.js";
|
|
15
18
|
import { PresenceClient, type MqttConnectFn } from "./presence.js";
|
|
@@ -19,6 +22,7 @@ export declare const SPOOL_DEBOUNCE_MS = 2000;
|
|
|
19
22
|
export declare const FLUSH_INTERVAL_MS = 10000;
|
|
20
23
|
/** Keep startup recovery bounded: the next daemon start continues the backlog. */
|
|
21
24
|
export declare const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
|
|
25
|
+
export declare const DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS = 2000;
|
|
22
26
|
/** Non-zero exit reserved for CLI version drift so legacy Restart=on-failure units restart. */
|
|
23
27
|
export declare const DAEMON_VERSION_DRIFT_EXIT_CODE = 75;
|
|
24
28
|
/**
|
|
@@ -79,6 +83,13 @@ export interface DaemonRunDeps {
|
|
|
79
83
|
exit?: (code: number) => void;
|
|
80
84
|
/** Token provider (tests). */
|
|
81
85
|
getToken?: () => Promise<string>;
|
|
86
|
+
/** Low-level tenant I/O seams used by authorization regression tests. */
|
|
87
|
+
poster?: SessionEventsPoster;
|
|
88
|
+
deliver?: DeliverFn;
|
|
89
|
+
readBoard?: BoardReader;
|
|
90
|
+
resolveMembership?: (token: string, slug: string, signal?: AbortSignal) => Promise<{
|
|
91
|
+
companyUid: string;
|
|
92
|
+
} | null>;
|
|
82
93
|
log?: (dir: string, line: string) => void;
|
|
83
94
|
/** Stop after this many ms (tests). */
|
|
84
95
|
runForMs?: number;
|
|
@@ -13,10 +13,13 @@ import * as os from "node:os";
|
|
|
13
13
|
import * as path from "node:path";
|
|
14
14
|
import { CLI_VERSION } from "../../../../cli-version.js";
|
|
15
15
|
import { createWorkSessionDeliverer } from "../../client.js";
|
|
16
|
-
import { meshJson, resolveActiveMembershipCompany } from "../../api.js";
|
|
16
|
+
import { listActiveMembershipCompanies, meshJson, resolveActiveMembershipCompany } from "../../api.js";
|
|
17
17
|
import { ensureCognitoToken } from "../../../../utils/cognito-session.js";
|
|
18
18
|
import { replayOutbox } from "../../../work-context/outbox.js";
|
|
19
19
|
import { workContextRoot } from "../../../work-context/paths.js";
|
|
20
|
+
import { ensureDefaultCompany } from "../../../work-context/default-company.js";
|
|
21
|
+
import { invalidateDefaultCompanyMemberships, readDeviceConfig, } from "../../../work-context/config.js";
|
|
22
|
+
import { readSessionState, writeSessionState, } from "../../../work-context/state.js";
|
|
20
23
|
import { createSessionEventsPoster, resolveVaultApiBase, } from "../session-events-client.js";
|
|
21
24
|
import { flushSessionEvents, HELD_RETRY_INTERVAL_MS, } from "../flush.js";
|
|
22
25
|
import { backfillHeldSessions, } from "../backfill-held.js";
|
|
@@ -28,7 +31,7 @@ import { appendDaemonLog, daemonLogLine, ensureDaemonLog } from "./log.js";
|
|
|
28
31
|
import { acquirePidLock, defaultPidLockDeps, releasePidLock, } from "./pid-lock.js";
|
|
29
32
|
import { daemonDir } from "./paths.js";
|
|
30
33
|
import { PresenceClient, } from "./presence.js";
|
|
31
|
-
import { defaultDaemonState, patchDaemonState, writeDaemonState, } from "./state.js";
|
|
34
|
+
import { defaultDaemonState, patchDaemonState, readDaemonState, writeDaemonState, } from "./state.js";
|
|
32
35
|
import { workMeshHeldPath } from "../paths.js";
|
|
33
36
|
import { noteHookSessionsFromSpoolFile, TRANSCRIPT_WATCH_INTERVAL_MS, TranscriptWatcher, } from "./transcript-watch.js";
|
|
34
37
|
import { resolveMeshEmitMode, runsEmitPath, } from "./mode.js";
|
|
@@ -38,6 +41,7 @@ export const SPOOL_DEBOUNCE_MS = 2_000;
|
|
|
38
41
|
export const FLUSH_INTERVAL_MS = 10_000;
|
|
39
42
|
/** Keep startup recovery bounded: the next daemon start continues the backlog. */
|
|
40
43
|
export const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
|
|
44
|
+
export const DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS = 2_000;
|
|
41
45
|
/** Non-zero exit reserved for CLI version drift so legacy Restart=on-failure units restart. */
|
|
42
46
|
export const DAEMON_VERSION_DRIFT_EXIT_CODE = 75;
|
|
43
47
|
/**
|
|
@@ -111,12 +115,69 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
111
115
|
// daemon went dark once its IoT credentials expired.
|
|
112
116
|
const defaultGetToken = async () => ensureCognitoToken({ interactive: false, tokenSource: "machine" });
|
|
113
117
|
const getToken = deps.getToken ?? defaultGetToken;
|
|
118
|
+
const resolveMembership = deps.resolveMembership ?? resolveActiveMembershipCompany;
|
|
119
|
+
/** The sole authorization seam for daemon tenant I/O derived from a device default. */
|
|
120
|
+
const tenantGate = {
|
|
121
|
+
authorize: async (input) => {
|
|
122
|
+
const state = input.state ?? (input.sessionId
|
|
123
|
+
? readSessionState(input.sessionId, ctxRoot)
|
|
124
|
+
: null);
|
|
125
|
+
const slug = input.companySlug ?? state?.companySlug;
|
|
126
|
+
const membership = await (async () => {
|
|
127
|
+
try {
|
|
128
|
+
if (!slug)
|
|
129
|
+
return null;
|
|
130
|
+
const controller = new AbortController();
|
|
131
|
+
const timeout = setTimeout(() => controller.abort(), DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS);
|
|
132
|
+
try {
|
|
133
|
+
return await Promise.race([
|
|
134
|
+
resolveMembership(await getToken(), slug, controller.signal),
|
|
135
|
+
new Promise((resolve) => controller.signal.addEventListener("abort", () => resolve(null), { once: true })),
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
clearTimeout(timeout);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
})();
|
|
146
|
+
const uid = membership?.companyUid;
|
|
147
|
+
if (!input.deviceDefault)
|
|
148
|
+
return uid ? { uid } : false;
|
|
149
|
+
if (uid &&
|
|
150
|
+
(!input.companyUid || input.companyUid === uid) &&
|
|
151
|
+
(!state?.companyUid || state.companyUid === uid)) {
|
|
152
|
+
return { uid };
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
invalidateDefaultCompanyMemberships({ root: ctxRoot, now });
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
log(dir, `default membership cache invalidation failed: ${String(err).slice(0, 160)}`);
|
|
159
|
+
}
|
|
160
|
+
if (state) {
|
|
161
|
+
writeSessionState({
|
|
162
|
+
...state,
|
|
163
|
+
companyUid: undefined,
|
|
164
|
+
companySlug: undefined,
|
|
165
|
+
contextStatus: "needs_company",
|
|
166
|
+
updatedAt: now().toISOString(),
|
|
167
|
+
}, ctxRoot);
|
|
168
|
+
}
|
|
169
|
+
const prior = readDaemonState(dir)?.deviceDefaultAuthorizationDenials ?? 0;
|
|
170
|
+
patchDaemonState(dir, { deviceDefaultAuthorizationDenials: prior + 1 }, now);
|
|
171
|
+
log(dir, `device-default membership denied${slug ? ` company=${slug}` : ""}; work held`);
|
|
172
|
+
return false;
|
|
173
|
+
},
|
|
174
|
+
};
|
|
114
175
|
/** In-memory held re-attempt throttle (survives watch-triggered flushes). */
|
|
115
176
|
let lastHeldRetryAtMs = 0;
|
|
116
177
|
const flushFn = deps.flush ??
|
|
117
178
|
(async () => {
|
|
118
179
|
const t = await getToken();
|
|
119
|
-
const poster = createSessionEventsPoster({
|
|
180
|
+
const poster = deps.poster ?? createSessionEventsPoster({
|
|
120
181
|
token: t,
|
|
121
182
|
baseUrl: resolveVaultApiBase(env),
|
|
122
183
|
});
|
|
@@ -130,32 +191,81 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
130
191
|
lastHeldRetryAtMs = atMs;
|
|
131
192
|
},
|
|
132
193
|
log: (message) => log(dir, message),
|
|
194
|
+
authorizeDeviceDefaultTenant: async (state) => Boolean(await tenantGate.authorize({
|
|
195
|
+
state,
|
|
196
|
+
companySlug: state.companySlug,
|
|
197
|
+
companyUid: state.companyUid,
|
|
198
|
+
deviceDefault: true,
|
|
199
|
+
})),
|
|
133
200
|
});
|
|
134
201
|
});
|
|
135
202
|
const replayFn = deps.replayOutbox ??
|
|
136
203
|
(async () => {
|
|
137
204
|
const t = await getToken();
|
|
138
|
-
const deliver = createWorkSessionDeliverer({ token: t });
|
|
139
|
-
return replayOutbox(ctxRoot,
|
|
205
|
+
const deliver = deps.deliver ?? createWorkSessionDeliverer({ token: t });
|
|
206
|
+
return replayOutbox(ctxRoot, async (op) => {
|
|
207
|
+
const state = readSessionState(op.sessionId, ctxRoot);
|
|
208
|
+
// Provenance must survive session-state loss. For pre-upgrade outbox
|
|
209
|
+
// entries without provenance, absent state is unknown and therefore
|
|
210
|
+
// requires the same membership check before delivery.
|
|
211
|
+
const requiresMembershipCheck = op.companyConfidence === "device_default" ||
|
|
212
|
+
state?.companyConfidence === "device_default" ||
|
|
213
|
+
// Pre-provenance entries are untrusted even if an equally legacy
|
|
214
|
+
// session state happens to exist. A current membership read is the
|
|
215
|
+
// only authority that may release them.
|
|
216
|
+
!op.companyConfidence;
|
|
217
|
+
if (requiresMembershipCheck) {
|
|
218
|
+
const allowed = await tenantGate.authorize({
|
|
219
|
+
state: state ?? undefined,
|
|
220
|
+
companySlug: op.companySlug ?? state?.companySlug,
|
|
221
|
+
companyUid: op.companyUid ?? state?.companyUid,
|
|
222
|
+
deviceDefault: true,
|
|
223
|
+
});
|
|
224
|
+
if (!allowed) {
|
|
225
|
+
return { ok: false, retryable: false, code: "DEVICE_DEFAULT_MEMBERSHIP_DENIED" };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return deliver(op);
|
|
229
|
+
});
|
|
140
230
|
});
|
|
231
|
+
const activateDefaultCompany = async () => {
|
|
232
|
+
try {
|
|
233
|
+
const token = await getToken();
|
|
234
|
+
await ensureDefaultCompany({
|
|
235
|
+
root: ctxRoot,
|
|
236
|
+
env,
|
|
237
|
+
listMemberships: () => listActiveMembershipCompanies(token),
|
|
238
|
+
log: (message) => log(dir, message),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
243
|
+
log(dir, `default company activation skipped: ${message.slice(0, 160)}`);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
141
246
|
const backfillFn = deps.backfillHeld ??
|
|
142
247
|
(async () => {
|
|
248
|
+
await activateDefaultCompany();
|
|
143
249
|
const token = await getToken();
|
|
144
250
|
const deliver = createWorkSessionDeliverer({ token });
|
|
251
|
+
const useDeviceDefault = readDeviceConfig({ root: ctxRoot }).repairHeldWithDefault === true;
|
|
145
252
|
return backfillHeldSessions({
|
|
146
253
|
workMeshRoot: meshRoot,
|
|
147
254
|
workContextRoot: ctxRoot,
|
|
148
255
|
hqRoots: trustedRoots,
|
|
149
256
|
limit: deps.heldBackfillBatchSize ?? HELD_BACKFILL_STARTUP_BATCH_SIZE,
|
|
150
257
|
env,
|
|
258
|
+
useDeviceDefault,
|
|
151
259
|
reconcile: (observation) => reconcileObservation(observation, {
|
|
152
260
|
root: ctxRoot,
|
|
153
261
|
env,
|
|
154
262
|
deliver,
|
|
155
|
-
validateMembership: async (slug) => {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
},
|
|
263
|
+
validateMembership: async (slug) => tenantGate.authorize({
|
|
264
|
+
companySlug: slug,
|
|
265
|
+
deviceDefault: useDeviceDefault,
|
|
266
|
+
}),
|
|
267
|
+
allowDeviceDefault: useDeviceDefault,
|
|
268
|
+
deviceDefaultConfidence: useDeviceDefault,
|
|
159
269
|
}),
|
|
160
270
|
});
|
|
161
271
|
});
|
|
@@ -163,7 +273,7 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
163
273
|
const refreshFn = deps.refreshBoards ??
|
|
164
274
|
(async () => {
|
|
165
275
|
const t = await getToken();
|
|
166
|
-
const reader = createVaultBoardReader({
|
|
276
|
+
const reader = deps.readBoard ?? createVaultBoardReader({
|
|
167
277
|
getJson: (p) => meshJson(t, p),
|
|
168
278
|
});
|
|
169
279
|
return refreshBoundSessionBoards({
|
|
@@ -172,6 +282,12 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
172
282
|
lastRefresh: boardLast,
|
|
173
283
|
intervalMs: BOARD_REFRESH_INTERVAL_MS,
|
|
174
284
|
now: () => timers.now(),
|
|
285
|
+
authorizeDeviceDefaultTenant: async (_sessionId, state) => Boolean(await tenantGate.authorize({
|
|
286
|
+
state,
|
|
287
|
+
companySlug: state.companySlug,
|
|
288
|
+
companyUid: state.companyUid,
|
|
289
|
+
deviceDefault: true,
|
|
290
|
+
})),
|
|
175
291
|
});
|
|
176
292
|
});
|
|
177
293
|
let presence = null;
|
|
@@ -490,6 +606,9 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
490
606
|
if (!requestRestartForVersionDrift() && emitPath) {
|
|
491
607
|
void doFlushCycle("start");
|
|
492
608
|
}
|
|
609
|
+
else {
|
|
610
|
+
void activateDefaultCompany();
|
|
611
|
+
}
|
|
493
612
|
void refreshFn().catch(() => undefined);
|
|
494
613
|
if (emitPath) {
|
|
495
614
|
void runTranscriptTick();
|
|
@@ -27,6 +27,8 @@ export interface DaemonStateFile {
|
|
|
27
27
|
presenceRefusal?: PresenceRefusalState | null;
|
|
28
28
|
/** Daemon emit mode: "legacy" (spool/flush) or "direct" (receive-only). */
|
|
29
29
|
emitMode?: "legacy" | "direct";
|
|
30
|
+
/** Authoritative membership denials for device-default-derived tenant I/O. */
|
|
31
|
+
deviceDefaultAuthorizationDenials?: number;
|
|
30
32
|
updatedAt: string;
|
|
31
33
|
}
|
|
32
34
|
export declare function defaultDaemonState(pid: number, now?: () => Date): DaemonStateFile;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* Orphan `*.claimed` files from a prior crash are recovered first.
|
|
9
9
|
* 2xx responses are accounted per eventId (accepted / duplicates / rejected).
|
|
10
10
|
*/
|
|
11
|
+
import { type SessionStateFile } from "../../work-context/state.js";
|
|
11
12
|
import { type RandomFn, type SleepFn } from "./backoff.js";
|
|
12
13
|
import { type SessionEventsPoster } from "./session-events-client.js";
|
|
13
14
|
export declare const HELD_TTL_MS: number;
|
|
@@ -43,6 +44,8 @@ export interface FlushDeps {
|
|
|
43
44
|
heldMaxLines?: number;
|
|
44
45
|
/** Optional logger (daemon wires this); used once per overflow trim. */
|
|
45
46
|
log?: (message: string) => void;
|
|
47
|
+
/** Authoritative gate for state derived from a device default. */
|
|
48
|
+
authorizeDeviceDefaultTenant?: (state: SessionStateFile) => Promise<boolean>;
|
|
46
49
|
}
|
|
47
50
|
export interface FlushSummary {
|
|
48
51
|
claimed: number;
|
|
@@ -218,6 +218,17 @@ export async function flushSessionEvents(deps) {
|
|
|
218
218
|
};
|
|
219
219
|
for (const [sessionId, items] of bySession) {
|
|
220
220
|
const state = readSessionState(sessionId, deps.workContextRoot);
|
|
221
|
+
if (state?.companyConfidence === "device_default" &&
|
|
222
|
+
deps.authorizeDeviceDefaultTenant &&
|
|
223
|
+
!(await deps.authorizeDeviceDefaultTenant(state))) {
|
|
224
|
+
const heldAt = now().toISOString();
|
|
225
|
+
for (const item of items) {
|
|
226
|
+
appendHeldLine(heldEnvelope(item.event, "NEEDS_COMPANY", item.heldAt ?? heldAt), deps.workMeshRoot);
|
|
227
|
+
summary.held += 1;
|
|
228
|
+
markDisposed(item.event);
|
|
229
|
+
}
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
221
232
|
const decision = classifySession(state);
|
|
222
233
|
if (decision.action === "drop") {
|
|
223
234
|
appendDroppedReceipt({
|
|
@@ -65,6 +65,8 @@ export interface CompanyResolveInput {
|
|
|
65
65
|
* session-meta. Omitted (or unmapped) → the ladder falls through unchanged.
|
|
66
66
|
*/
|
|
67
67
|
repoIdentityKey?: string | null;
|
|
68
|
+
/** Explicit historical repair may disable weak device-default evidence. */
|
|
69
|
+
allowDeviceDefault?: boolean;
|
|
68
70
|
}
|
|
69
71
|
/** Env naming the on-box identity.json (fleet agent boxes). */
|
|
70
72
|
export declare const HQ_AGENT_IDENTITY_FILE_ENV = "HQ_AGENT_IDENTITY_FILE";
|
|
@@ -76,6 +78,12 @@ export declare const DEFAULT_AGENT_IDENTITY_FILE = "/var/lib/hq-agent/identity.j
|
|
|
76
78
|
* (never throws; never logs file contents).
|
|
77
79
|
*/
|
|
78
80
|
export declare function readAgentIdentityCompanyUid(env?: NodeJS.ProcessEnv): string | undefined;
|
|
81
|
+
/**
|
|
82
|
+
* Agent boxes must never infer a human device default. The file itself is the
|
|
83
|
+
* machine marker; legacy identity files do not necessarily contain a company
|
|
84
|
+
* UID, so parsing for that field is not an eligibility check.
|
|
85
|
+
*/
|
|
86
|
+
export declare function hasAgentIdentityFile(env?: NodeJS.ProcessEnv): boolean;
|
|
79
87
|
/**
|
|
80
88
|
* Compare companies: UIDs when both sides have them; otherwise normalized
|
|
81
89
|
* slugs when both have slugs. Never treat uid+slug vs slug-only as a
|
|
@@ -63,6 +63,23 @@ export function readAgentIdentityCompanyUid(env = process.env) {
|
|
|
63
63
|
return undefined;
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Agent boxes must never infer a human device default. The file itself is the
|
|
68
|
+
* machine marker; legacy identity files do not necessarily contain a company
|
|
69
|
+
* UID, so parsing for that field is not an eligibility check.
|
|
70
|
+
*/
|
|
71
|
+
export function hasAgentIdentityFile(env = process.env) {
|
|
72
|
+
const configured = env[HQ_AGENT_IDENTITY_FILE_ENV];
|
|
73
|
+
const identityPath = configured === undefined ? DEFAULT_AGENT_IDENTITY_FILE : configured.trim();
|
|
74
|
+
if (!identityPath)
|
|
75
|
+
return false;
|
|
76
|
+
try {
|
|
77
|
+
return fs.existsSync(identityPath);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
66
83
|
/**
|
|
67
84
|
* Compare companies: UIDs when both sides have them; otherwise normalized
|
|
68
85
|
* slugs when both have slugs. Never treat uid+slug vs slug-only as a
|
|
@@ -178,6 +195,16 @@ export function resolveDeterministicCompany(input) {
|
|
|
178
195
|
}
|
|
179
196
|
return null;
|
|
180
197
|
}
|
|
198
|
+
function conflictingDeterministicEvidence(input) {
|
|
199
|
+
const cwd = companySlugFromCwd(input.cwd, input.hqRoot);
|
|
200
|
+
const remote = normalizeSlug(input.remoteOwnerSlug ?? undefined);
|
|
201
|
+
if (!cwd || !remote || cwd.toLowerCase() === remote.toLowerCase())
|
|
202
|
+
return null;
|
|
203
|
+
return {
|
|
204
|
+
cwd: { slug: cwd, source: "deterministic_cwd" },
|
|
205
|
+
remote: { slug: remote, source: "deterministic_remote" },
|
|
206
|
+
};
|
|
207
|
+
}
|
|
181
208
|
export function resolveCompany(input) {
|
|
182
209
|
const env = input.env ?? process.env;
|
|
183
210
|
// 1. Trusted explicit: HQ_SPAWN_COMPANY or observation trusted/explicit.
|
|
@@ -205,28 +232,31 @@ export function resolveCompany(input) {
|
|
|
205
232
|
uid: prior.uid,
|
|
206
233
|
source: "existing_scope",
|
|
207
234
|
};
|
|
208
|
-
//
|
|
209
|
-
//
|
|
235
|
+
// Deterministic evidence from another company never silently replaces a
|
|
236
|
+
// scoped session, even before it has a project binding. Preserve the
|
|
237
|
+
// original scope and surface a conflict before tenant I/O.
|
|
210
238
|
const hadBinding = Boolean(state &&
|
|
211
239
|
(state.contextStatus === "bound" ||
|
|
212
240
|
state.contextStatus === "needs_task" ||
|
|
213
241
|
state.contextStatus === "needs_project") &&
|
|
214
242
|
(state.projectId || state.bindingEpisodeId));
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
243
|
+
const deterministic = resolveDeterministicCompany({
|
|
244
|
+
cwd: input.cwd,
|
|
245
|
+
hqRoot: input.hqRoot,
|
|
246
|
+
remoteOwnerSlug: input.remoteOwnerSlug,
|
|
247
|
+
});
|
|
248
|
+
if (deterministic && !sameCompany(scoped, deterministic)) {
|
|
249
|
+
return {
|
|
250
|
+
status: "company_conflict",
|
|
251
|
+
defaultCompany: scoped,
|
|
252
|
+
evidenceCompany: deterministic,
|
|
253
|
+
...(hadBinding
|
|
254
|
+
? {
|
|
255
|
+
afterBinding: true,
|
|
256
|
+
correctionPath: companyCorrectionPath(input.sessionId),
|
|
257
|
+
}
|
|
258
|
+
: {}),
|
|
259
|
+
};
|
|
230
260
|
}
|
|
231
261
|
return { status: "resolved", company: scoped };
|
|
232
262
|
}
|
|
@@ -264,12 +294,26 @@ export function resolveCompany(input) {
|
|
|
264
294
|
}
|
|
265
295
|
}
|
|
266
296
|
// 5 + 6. Device default vs deterministic evidence.
|
|
267
|
-
const deviceDefault = getDefaultCompany({ root: input.root });
|
|
297
|
+
const deviceDefault = input.allowDeviceDefault === false ? null : getDefaultCompany({ root: input.root });
|
|
268
298
|
const deterministic = resolveDeterministicCompany({
|
|
269
299
|
cwd: input.cwd,
|
|
270
300
|
hqRoot: input.hqRoot,
|
|
271
301
|
remoteOwnerSlug: input.remoteOwnerSlug,
|
|
272
302
|
});
|
|
303
|
+
const deterministicConflict = conflictingDeterministicEvidence({
|
|
304
|
+
cwd: input.cwd,
|
|
305
|
+
hqRoot: input.hqRoot,
|
|
306
|
+
remoteOwnerSlug: input.remoteOwnerSlug,
|
|
307
|
+
});
|
|
308
|
+
// Cwd and remote are both deterministic evidence. Their disagreement is
|
|
309
|
+
// never permission to fall through to a weaker device preference.
|
|
310
|
+
if (deterministicConflict) {
|
|
311
|
+
return {
|
|
312
|
+
status: "company_conflict",
|
|
313
|
+
defaultCompany: deterministicConflict.cwd,
|
|
314
|
+
evidenceCompany: deterministicConflict.remote,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
273
317
|
if (deviceDefault?.enabled && deviceDefault.slug) {
|
|
274
318
|
const def = {
|
|
275
319
|
slug: deviceDefault.slug,
|