@indigoai-us/hq-cli 5.124.0 → 5.126.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 +149 -0
- package/assets/bot-workers/setup/skills/joining-a-team.md +125 -0
- package/assets/bot-workers/setup/skills/your-business.md +270 -0
- package/assets/bot-workers/setup/worker.yaml +189 -58
- package/dist/command-catalog.generated.d.ts +414 -5
- package/dist/command-catalog.generated.js +531 -5
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/bot.d.ts +10 -0
- package/dist/commands/bot.js +6 -0
- package/dist/commands/lanes.d.ts +75 -0
- package/dist/commands/lanes.js +2032 -0
- package/dist/commands/login.js +49 -6
- package/dist/commands/mesh.js +133 -28
- package/dist/commands/onboard-create-identity.d.ts +26 -0
- package/dist/commands/onboard-create-identity.js +23 -0
- package/dist/commands/onboard.js +13 -4
- package/dist/lib/bot/progress.d.ts +6 -0
- package/dist/lib/bot/progress.js +16 -0
- package/dist/lib/bot/runtime/grok.d.ts +30 -7
- package/dist/lib/bot/runtime/grok.js +86 -8
- package/dist/lib/bot/runtime/index.js +15 -8
- package/dist/lib/bot/runtime/messages-stream.d.ts +12 -3
- package/dist/lib/bot/runtime/messages-stream.js +49 -3
- package/dist/lib/doctor/checks/work-context.js +4 -2
- package/dist/lib/lanes/accounts.d.ts +43 -0
- package/dist/lib/lanes/accounts.js +128 -0
- package/dist/lib/lanes/config.d.ts +56 -0
- package/dist/lib/lanes/config.js +351 -0
- package/dist/lib/lanes/errors.d.ts +39 -0
- package/dist/lib/lanes/errors.js +72 -0
- package/dist/lib/lanes/index.d.ts +19 -0
- package/dist/lib/lanes/index.js +12 -0
- package/dist/lib/lanes/liveness.d.ts +89 -0
- package/dist/lib/lanes/liveness.js +273 -0
- package/dist/lib/lanes/registry.d.ts +103 -0
- package/dist/lib/lanes/registry.js +247 -0
- package/dist/lib/lanes/store.d.ts +64 -0
- package/dist/lib/lanes/store.js +627 -0
- package/dist/lib/lanes/throttle.d.ts +89 -0
- package/dist/lib/lanes/throttle.js +297 -0
- package/dist/lib/lanes/types.d.ts +193 -0
- package/dist/lib/lanes/types.js +217 -0
- 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/config.d.ts +27 -0
- package/dist/lib/mesh/live/daemon/config.js +75 -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/index.d.ts +4 -3
- package/dist/lib/mesh/live/daemon/index.js +3 -3
- package/dist/lib/mesh/live/daemon/install.d.ts +143 -1
- package/dist/lib/mesh/live/daemon/install.js +444 -2
- package/dist/lib/mesh/live/daemon/paths.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/paths.js +2 -0
- 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/scan-packages/index.js +17 -12
- 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 +55 -0
- package/dist/lib/work-context/config.js +333 -67
- 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 +86 -9
- package/dist/lib/work-context/state.d.ts +2 -0
- package/dist/lib/work-context/state.js +14 -0
- package/dist/lib/work-context/windows-acl.d.ts +123 -0
- package/dist/lib/work-context/windows-acl.js +425 -0
- package/dist/utils/pack-contributions.js +5 -4
- package/package.json +1 -1
|
@@ -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({
|
|
@@ -163,34 +163,39 @@ function ensureSymlink(link, info, warn) {
|
|
|
163
163
|
warn(`payload missing: ${link.src} (declared but not shipped)`);
|
|
164
164
|
return;
|
|
165
165
|
}
|
|
166
|
+
const relativeSrc = path.relative(path.dirname(link.dst), link.src);
|
|
166
167
|
fs.mkdirSync(path.dirname(link.dst), { recursive: true });
|
|
167
168
|
try {
|
|
168
169
|
const stat = fs.lstatSync(link.dst);
|
|
169
170
|
if (stat.isSymbolicLink()) {
|
|
170
171
|
const existing = fs.readlinkSync(link.dst);
|
|
171
|
-
|
|
172
|
+
// Correct form is relative to the host directory so a tree can move
|
|
173
|
+
// between machines. An absolute match (this host, pre-relative installer)
|
|
174
|
+
// is ours and converges. A dangling leftover from another HQ root is
|
|
175
|
+
// not host content and is rewritten the same way.
|
|
176
|
+
if (existing === relativeSrc)
|
|
172
177
|
return;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
178
|
+
if (existing === link.src) {
|
|
179
|
+
fs.rmSync(link.dst, { force: true });
|
|
180
|
+
fs.symlinkSync(relativeSrc, link.dst);
|
|
181
|
+
info(`relinked ${link.dst} -> ${relativeSrc} (was ${existing})`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
179
184
|
// Only a lookup that specifically says "not there" earns a relink.
|
|
180
185
|
// fs.existsSync also answers false for EACCES, which would read content
|
|
181
186
|
// this user merely cannot traverse as absent and delete the link to it.
|
|
182
187
|
const lookup = targetLookup(link.dst);
|
|
183
188
|
if (lookup === 'absent') {
|
|
184
189
|
fs.rmSync(link.dst, { force: true });
|
|
185
|
-
fs.symlinkSync(
|
|
186
|
-
info(`relinked ${link.dst} -> ${
|
|
190
|
+
fs.symlinkSync(relativeSrc, link.dst);
|
|
191
|
+
info(`relinked ${link.dst} -> ${relativeSrc} (was ${existing}, which does not exist here)`);
|
|
187
192
|
return;
|
|
188
193
|
}
|
|
189
194
|
if (lookup !== 'present') {
|
|
190
195
|
warn(`collision: ${link.dst} points at ${existing}, which could not be checked (${lookup}) — leaving it alone`);
|
|
191
196
|
return;
|
|
192
197
|
}
|
|
193
|
-
warn(`collision: ${link.dst} already points at ${existing} (wanted ${
|
|
198
|
+
warn(`collision: ${link.dst} already points at ${existing} (wanted ${relativeSrc}) — skipping`);
|
|
194
199
|
return;
|
|
195
200
|
}
|
|
196
201
|
warn(`collision: ${link.dst} exists as regular file/dir — host content wins, skipping`);
|
|
@@ -200,8 +205,8 @@ function ensureSymlink(link, info, warn) {
|
|
|
200
205
|
if (error.code !== 'ENOENT')
|
|
201
206
|
throw error;
|
|
202
207
|
}
|
|
203
|
-
fs.symlinkSync(
|
|
204
|
-
info(`linked ${link.dst} -> ${
|
|
208
|
+
fs.symlinkSync(relativeSrc, link.dst);
|
|
209
|
+
info(`linked ${link.dst} -> ${relativeSrc}`);
|
|
205
210
|
}
|
|
206
211
|
/**
|
|
207
212
|
* The link text, when `entry` is a symlink whose target is definitively not
|
|
@@ -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,
|
|
@@ -2,12 +2,25 @@
|
|
|
2
2
|
* Device-local default company preference (~/.hq/work-context/config.json).
|
|
3
3
|
* Preference only — never authority. activeCompany is never treated as default.
|
|
4
4
|
*/
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import { type WindowsAclCheckOptions } from "./windows-acl.js";
|
|
5
7
|
import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
|
|
6
8
|
export declare const DEVICE_CONFIG_SCHEMA_VERSION: 1;
|
|
7
9
|
export interface DeviceDefaultCompany {
|
|
8
10
|
slug: string;
|
|
9
11
|
uid?: string;
|
|
10
12
|
enabled: boolean;
|
|
13
|
+
/** Whether this device chose the value or safely inferred its only membership. */
|
|
14
|
+
source?: "auto_single" | "chosen" | "disabled";
|
|
15
|
+
}
|
|
16
|
+
export interface DefaultCompanyMembership {
|
|
17
|
+
slug: string;
|
|
18
|
+
uid: string;
|
|
19
|
+
}
|
|
20
|
+
/** Cached active memberships. This avoids a network lookup for each hook. */
|
|
21
|
+
export interface DefaultCompanyMembershipSnapshot {
|
|
22
|
+
checkedAt: string;
|
|
23
|
+
memberships: DefaultCompanyMembership[];
|
|
11
24
|
}
|
|
12
25
|
/** Last probe of GET /v1/work-mesh/capabilities across memberships (US-017B). */
|
|
13
26
|
export interface MigrationCapabilitySnapshot {
|
|
@@ -36,10 +49,16 @@ export interface WorkContextDeviceConfig {
|
|
|
36
49
|
schemaVersion: typeof DEVICE_CONFIG_SCHEMA_VERSION;
|
|
37
50
|
/** Explicit device default. Never copied from activeCompany. */
|
|
38
51
|
defaultCompany?: DeviceDefaultCompany | null;
|
|
52
|
+
/** Marks a null default written by the automatic-activation implementation. */
|
|
53
|
+
defaultCompanyAutomaticEligible?: true;
|
|
39
54
|
/** Last-known migration capability across memberships (doctor reports this). */
|
|
40
55
|
migrationCapability?: MigrationCapabilitySnapshot | null;
|
|
41
56
|
/** Per-repo company map (gap 4). Remembers each repo's filed-under company. */
|
|
42
57
|
repoCompanyMap?: RepoCompanyMap | null;
|
|
58
|
+
/** Membership cache used to activate an unconfigured device default. */
|
|
59
|
+
defaultCompanyMemberships?: DefaultCompanyMembershipSnapshot | null;
|
|
60
|
+
/** Historical held-session repair stays opt-in because a default is weak evidence. */
|
|
61
|
+
repairHeldWithDefault?: boolean;
|
|
43
62
|
updatedAt: string;
|
|
44
63
|
}
|
|
45
64
|
/** true / { uid } = member; false = not a member. */
|
|
@@ -58,8 +77,34 @@ export interface DeviceConfigDeps {
|
|
|
58
77
|
validateMembership?: (slug: string) => Promise<MembershipValidationResult> | MembershipValidationResult;
|
|
59
78
|
now?: () => Date;
|
|
60
79
|
}
|
|
80
|
+
export declare const CONFIG_LOCK_STALE_MS = 30000;
|
|
81
|
+
/** Acquire an interprocess config lock; exported for direct lock regression tests. */
|
|
82
|
+
export declare function acquireDeviceConfigLock(root: string, options?: {
|
|
83
|
+
timeoutMs?: number;
|
|
84
|
+
staleMs?: number;
|
|
85
|
+
}): () => void;
|
|
86
|
+
/** Injection points for assertSafeConfigPath (tests only). */
|
|
87
|
+
export interface SafeConfigPathIo {
|
|
88
|
+
platform?: NodeJS.Platform;
|
|
89
|
+
existsSync?: (p: string) => boolean;
|
|
90
|
+
lstatSync?: (p: string) => Pick<fs.Stats, "mode" | "isSymbolicLink">;
|
|
91
|
+
windowsAcl?: WindowsAclCheckOptions;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Refuse a config path that is a symlink (every platform) or writable by
|
|
95
|
+
* anyone other than the owner.
|
|
96
|
+
*
|
|
97
|
+
* - POSIX (darwin/linux/...): `mode & 0o022` must be clear. Unchanged.
|
|
98
|
+
* - win32: NTFS has no POSIX mode bits; Node reports 0666 for every regular
|
|
99
|
+
* file, so the mode test would reject every config (US-018). We read the
|
|
100
|
+
* real DACL instead (see windows-acl.ts). Unreadable DACL → documented
|
|
101
|
+
* reduced-guarantee path: one diagnostic line, no throw.
|
|
102
|
+
*/
|
|
103
|
+
export declare function assertSafeConfigPath(configPath: string, io?: SafeConfigPathIo): void;
|
|
61
104
|
export declare function readDeviceConfig(deps: Pick<DeviceConfigDeps, "root">): WorkContextDeviceConfig;
|
|
62
105
|
export declare function getDefaultCompany(deps: Pick<DeviceConfigDeps, "root">): DeviceDefaultCompany | null;
|
|
106
|
+
/** A clear is a durable opt-out, not an invitation for automatic reactivation. */
|
|
107
|
+
export declare function isDefaultCompanyDisabled(deps: Pick<DeviceConfigDeps, "root">): boolean;
|
|
63
108
|
export declare function setDefaultCompany(slug: string, deps: DeviceConfigDeps): Promise<WorkContextDeviceConfig>;
|
|
64
109
|
export declare function clearDefaultCompany(deps: DeviceConfigDeps): WorkContextDeviceConfig;
|
|
65
110
|
/**
|
|
@@ -77,6 +122,16 @@ export declare function setRepoCompany(key: string, mapping: {
|
|
|
77
122
|
}, deps: Pick<DeviceConfigDeps, "root" | "now">): WorkContextDeviceConfig;
|
|
78
123
|
/** Persist the last memberships-wide migration capability probe (US-017B). */
|
|
79
124
|
export declare function recordMigrationCapabilitySnapshot(snapshot: MigrationCapabilitySnapshot, deps: Pick<DeviceConfigDeps, "root" | "now">): WorkContextDeviceConfig;
|
|
125
|
+
export declare function recordDefaultCompanyMemberships(snapshot: DefaultCompanyMembershipSnapshot, deps: Pick<DeviceConfigDeps, "root" | "now">): WorkContextDeviceConfig;
|
|
126
|
+
/** Drop cached membership evidence immediately after an authoritative denial. */
|
|
127
|
+
export declare function invalidateDefaultCompanyMemberships(deps: Pick<DeviceConfigDeps, "root" | "now">): WorkContextDeviceConfig;
|
|
128
|
+
/** Persist a default only after the complete active-membership list verified it. */
|
|
129
|
+
export declare function setAutoDefaultCompany(membership: DefaultCompanyMembership, deps: Pick<DeviceConfigDeps, "root" | "now"> & {
|
|
130
|
+
expectedUpdatedAt?: string;
|
|
131
|
+
/** Test seam: runs after the guarded read while the config lock is held. */
|
|
132
|
+
afterCompareAndSwap?: () => void;
|
|
133
|
+
}): WorkContextDeviceConfig;
|
|
134
|
+
export declare function setRepairHeldWithDefault(enabled: boolean, deps: Pick<DeviceConfigDeps, "root" | "now">): WorkContextDeviceConfig;
|
|
80
135
|
/** Convenience: build deps from HOME / injectable root. */
|
|
81
136
|
export declare function deviceConfigDepsFromHome(home?: string, extras?: Partial<DeviceConfigDeps>): DeviceConfigDeps;
|
|
82
137
|
/** Re-export for callers that stamp contractVersion on related state. */
|