@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
|
@@ -7,14 +7,118 @@ import * as path from "node:path";
|
|
|
7
7
|
import { atomicWriteJson, ensureOwnerDir, resolveRealTarget } from "./atomic.js";
|
|
8
8
|
import { DefaultCompanyUnavailableError, UnsafeConfigPathError, WorkContextError, } from "./errors.js";
|
|
9
9
|
import { workContextConfigPath, workContextRoot } from "./paths.js";
|
|
10
|
+
import { assertWindowsOwnerOnlyAcl } from "./windows-acl.js";
|
|
10
11
|
import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
|
|
11
12
|
export const DEVICE_CONFIG_SCHEMA_VERSION = 1;
|
|
13
|
+
const CONFIG_LOCK_TIMEOUT_MS = 5_000;
|
|
14
|
+
const CONFIG_LOCK_RETRY_MS = 10;
|
|
15
|
+
export const CONFIG_LOCK_STALE_MS = 30_000;
|
|
16
|
+
function waitForConfigLock(ms) {
|
|
17
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
18
|
+
}
|
|
19
|
+
function readConfigLockRecord(lockPath) {
|
|
20
|
+
try {
|
|
21
|
+
const stat = fs.statSync(lockPath);
|
|
22
|
+
const raw = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
23
|
+
const pid = typeof raw.pid === "number" && Number.isInteger(raw.pid) ? raw.pid : 0;
|
|
24
|
+
const createdAt = typeof raw.createdAt === "string" ? raw.createdAt : "";
|
|
25
|
+
const token = typeof raw.token === "string" && raw.token ? raw.token : undefined;
|
|
26
|
+
const parsedAt = Date.parse(createdAt);
|
|
27
|
+
return {
|
|
28
|
+
record: pid > 0 && createdAt ? { pid, createdAt, token } : null,
|
|
29
|
+
ageMs: Date.now() - (Number.isFinite(parsedAt) ? parsedAt : stat.mtimeMs),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return { record: null, ageMs: Number.POSITIVE_INFINITY };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function isProcessAlive(pid) {
|
|
37
|
+
try {
|
|
38
|
+
process.kill(pid, 0);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function reclaimStaleConfigLock(lockPath) {
|
|
46
|
+
const claim = `${lockPath}.reclaim.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`;
|
|
47
|
+
try {
|
|
48
|
+
fs.renameSync(lockPath, claim);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (err.code === "ENOENT")
|
|
52
|
+
return false;
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
try {
|
|
60
|
+
fs.unlinkSync(claim);
|
|
61
|
+
}
|
|
62
|
+
catch { /* private claim cannot block writers */ }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Acquire an interprocess config lock; exported for direct lock regression tests. */
|
|
66
|
+
export function acquireDeviceConfigLock(root, options = {}) {
|
|
67
|
+
ensureOwnerDir(root);
|
|
68
|
+
const configPath = resolveRealTarget(workContextConfigPath(root));
|
|
69
|
+
assertSafeConfigPath(configPath);
|
|
70
|
+
const lockPath = `${configPath}.lock`;
|
|
71
|
+
const deadline = Date.now() + (options.timeoutMs ?? CONFIG_LOCK_TIMEOUT_MS);
|
|
72
|
+
const token = `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`;
|
|
73
|
+
let fd;
|
|
74
|
+
while (fd === undefined) {
|
|
75
|
+
try {
|
|
76
|
+
fd = fs.openSync(lockPath, "wx", 0o600);
|
|
77
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString(), token }));
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
if (err.code !== "EEXIST")
|
|
81
|
+
throw err;
|
|
82
|
+
const { record, ageMs } = readConfigLockRecord(lockPath);
|
|
83
|
+
// Age alone is not authority to steal a lock: a live writer may simply
|
|
84
|
+
// be slow. Only a missing/malformed or dead owner can be reclaimed.
|
|
85
|
+
if (!record || !isProcessAlive(record.pid)) {
|
|
86
|
+
reclaimStaleConfigLock(lockPath);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (Date.now() >= deadline) {
|
|
90
|
+
throw new WorkContextError("ConfigLockTimeout", "Timed out waiting for the work-context config lock");
|
|
91
|
+
}
|
|
92
|
+
waitForConfigLock(CONFIG_LOCK_RETRY_MS);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return () => {
|
|
96
|
+
fs.closeSync(fd);
|
|
97
|
+
try {
|
|
98
|
+
const current = readConfigLockRecord(lockPath).record;
|
|
99
|
+
if (current?.token === token)
|
|
100
|
+
fs.unlinkSync(lockPath);
|
|
101
|
+
}
|
|
102
|
+
catch { /* stale reclaimer already retired or replaced this lock */ }
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function withDeviceConfigLock(root, fn) {
|
|
106
|
+
const release = acquireDeviceConfigLock(root);
|
|
107
|
+
try {
|
|
108
|
+
return fn();
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
release();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
12
114
|
function emptyConfig(now) {
|
|
13
115
|
return {
|
|
14
116
|
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
15
117
|
defaultCompany: null,
|
|
16
118
|
migrationCapability: null,
|
|
17
119
|
repoCompanyMap: null,
|
|
120
|
+
defaultCompanyMemberships: null,
|
|
121
|
+
repairHeldWithDefault: false,
|
|
18
122
|
updatedAt: now().toISOString(),
|
|
19
123
|
};
|
|
20
124
|
}
|
|
@@ -44,20 +148,40 @@ function sanitizeRepoCompanyMap(raw) {
|
|
|
44
148
|
}
|
|
45
149
|
return Object.keys(out).length > 0 ? out : null;
|
|
46
150
|
}
|
|
47
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Refuse a config path that is a symlink (every platform) or writable by
|
|
153
|
+
* anyone other than the owner.
|
|
154
|
+
*
|
|
155
|
+
* - POSIX (darwin/linux/...): `mode & 0o022` must be clear. Unchanged.
|
|
156
|
+
* - win32: NTFS has no POSIX mode bits; Node reports 0666 for every regular
|
|
157
|
+
* file, so the mode test would reject every config (US-018). We read the
|
|
158
|
+
* real DACL instead (see windows-acl.ts). Unreadable DACL → documented
|
|
159
|
+
* reduced-guarantee path: one diagnostic line, no throw.
|
|
160
|
+
*/
|
|
161
|
+
export function assertSafeConfigPath(configPath, io = {}) {
|
|
162
|
+
const platform = io.platform ?? process.platform;
|
|
163
|
+
const existsSync = io.existsSync ?? fs.existsSync;
|
|
164
|
+
const lstatSync = io.lstatSync ?? fs.lstatSync;
|
|
48
165
|
const dir = path.dirname(configPath);
|
|
49
166
|
try {
|
|
50
|
-
if (
|
|
51
|
-
const lst =
|
|
167
|
+
if (existsSync(dir)) {
|
|
168
|
+
const lst = lstatSync(dir);
|
|
52
169
|
if (lst.isSymbolicLink()) {
|
|
53
170
|
throw new UnsafeConfigPathError(`Config directory is a symlink: ${dir}`);
|
|
54
171
|
}
|
|
172
|
+
if (platform === "win32") {
|
|
173
|
+
assertWindowsOwnerOnlyAcl(dir, "directory", io.windowsAcl);
|
|
174
|
+
}
|
|
55
175
|
}
|
|
56
|
-
if (
|
|
57
|
-
const lst =
|
|
176
|
+
if (existsSync(configPath)) {
|
|
177
|
+
const lst = lstatSync(configPath);
|
|
58
178
|
if (lst.isSymbolicLink()) {
|
|
59
179
|
throw new UnsafeConfigPathError(`Config file is a symlink: ${configPath}`);
|
|
60
180
|
}
|
|
181
|
+
if (platform === "win32") {
|
|
182
|
+
assertWindowsOwnerOnlyAcl(configPath, "file", io.windowsAcl);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
61
185
|
const mode = lst.mode & 0o777;
|
|
62
186
|
// Warn-level only for world-writable; refuse group/other write bits on read for safety.
|
|
63
187
|
if (mode & 0o022) {
|
|
@@ -82,11 +206,22 @@ export function readDeviceConfig(deps) {
|
|
|
82
206
|
throw new WorkContextError("ConfigCorruptionError", `Unsupported config schemaVersion ${String(raw.schemaVersion)}`);
|
|
83
207
|
}
|
|
84
208
|
// Never treat a stray activeCompany field as the default.
|
|
209
|
+
// Older `clear` commands persisted `defaultCompany: null`. A config file
|
|
210
|
+
// is durable user history, so preserving a null as "unconfigured" would
|
|
211
|
+
// re-enable automatic selection after an upgrade. Treat every persisted
|
|
212
|
+
// legacy null as the explicit opt-out tombstone instead.
|
|
213
|
+
const legacyNullDefault = Object.prototype.hasOwnProperty.call(raw, "defaultCompany") &&
|
|
214
|
+
raw.defaultCompany === null &&
|
|
215
|
+
raw.defaultCompanyAutomaticEligible !== true;
|
|
85
216
|
const cleaned = {
|
|
86
217
|
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
87
|
-
defaultCompany:
|
|
218
|
+
defaultCompany: legacyNullDefault
|
|
219
|
+
? { slug: "", enabled: false, source: "disabled" }
|
|
220
|
+
: raw.defaultCompany ?? null,
|
|
88
221
|
migrationCapability: raw.migrationCapability ?? null,
|
|
89
222
|
repoCompanyMap: sanitizeRepoCompanyMap(raw.repoCompanyMap),
|
|
223
|
+
defaultCompanyMemberships: sanitizeMembershipSnapshot(raw.defaultCompanyMemberships),
|
|
224
|
+
repairHeldWithDefault: raw.repairHeldWithDefault === true,
|
|
90
225
|
updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : new Date(0).toISOString(),
|
|
91
226
|
};
|
|
92
227
|
return cleaned;
|
|
@@ -101,7 +236,12 @@ function writeDeviceConfig(deps, config) {
|
|
|
101
236
|
ensureOwnerDir(deps.root);
|
|
102
237
|
const configPath = resolveRealTarget(workContextConfigPath(deps.root));
|
|
103
238
|
assertSafeConfigPath(configPath);
|
|
104
|
-
|
|
239
|
+
// New blank configuration is intentionally distinct from the legacy null
|
|
240
|
+
// that the previous `clear` command wrote. Preserve that distinction on
|
|
241
|
+
// disk so a membership-cache write cannot turn into a durable opt-out.
|
|
242
|
+
atomicWriteJson(configPath, config.defaultCompany === null
|
|
243
|
+
? { ...config, defaultCompanyAutomaticEligible: true }
|
|
244
|
+
: config);
|
|
105
245
|
}
|
|
106
246
|
export function getDefaultCompany(deps) {
|
|
107
247
|
const cfg = readDeviceConfig(deps);
|
|
@@ -110,6 +250,11 @@ export function getDefaultCompany(deps) {
|
|
|
110
250
|
return null;
|
|
111
251
|
return d;
|
|
112
252
|
}
|
|
253
|
+
/** A clear is a durable opt-out, not an invitation for automatic reactivation. */
|
|
254
|
+
export function isDefaultCompanyDisabled(deps) {
|
|
255
|
+
const configured = readDeviceConfig(deps).defaultCompany;
|
|
256
|
+
return configured?.source === "disabled";
|
|
257
|
+
}
|
|
113
258
|
export async function setDefaultCompany(slug, deps) {
|
|
114
259
|
const trimmed = slug.trim();
|
|
115
260
|
if (!trimmed || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trimmed)) {
|
|
@@ -135,35 +280,44 @@ export async function setDefaultCompany(slug, deps) {
|
|
|
135
280
|
resolvedUid = result.uid.trim() || undefined;
|
|
136
281
|
}
|
|
137
282
|
const now = deps.now ?? (() => new Date());
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
283
|
+
return withDeviceConfigLock(deps.root, () => {
|
|
284
|
+
const prior = readDeviceConfig(deps);
|
|
285
|
+
const defaultCompany = {
|
|
286
|
+
slug: trimmed,
|
|
287
|
+
enabled: true,
|
|
288
|
+
source: "chosen",
|
|
289
|
+
};
|
|
290
|
+
if (resolvedUid)
|
|
291
|
+
defaultCompany.uid = resolvedUid;
|
|
292
|
+
const next = {
|
|
293
|
+
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
294
|
+
defaultCompany,
|
|
295
|
+
migrationCapability: prior.migrationCapability ?? null,
|
|
296
|
+
repoCompanyMap: prior.repoCompanyMap ?? null,
|
|
297
|
+
defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
|
|
298
|
+
repairHeldWithDefault: prior.repairHeldWithDefault === true,
|
|
299
|
+
updatedAt: now().toISOString(),
|
|
300
|
+
};
|
|
301
|
+
writeDeviceConfig(deps, next);
|
|
302
|
+
return next;
|
|
303
|
+
});
|
|
154
304
|
}
|
|
155
305
|
export function clearDefaultCompany(deps) {
|
|
156
306
|
const now = deps.now ?? (() => new Date());
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
307
|
+
return withDeviceConfigLock(deps.root, () => {
|
|
308
|
+
const prior = readDeviceConfig(deps);
|
|
309
|
+
const next = {
|
|
310
|
+
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
311
|
+
defaultCompany: { slug: "", enabled: false, source: "disabled" },
|
|
312
|
+
migrationCapability: prior.migrationCapability ?? null,
|
|
313
|
+
repoCompanyMap: prior.repoCompanyMap ?? null,
|
|
314
|
+
defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
|
|
315
|
+
repairHeldWithDefault: prior.repairHeldWithDefault === true,
|
|
316
|
+
updatedAt: now().toISOString(),
|
|
317
|
+
};
|
|
318
|
+
writeDeviceConfig(deps, next);
|
|
319
|
+
return next;
|
|
320
|
+
});
|
|
167
321
|
}
|
|
168
322
|
/**
|
|
169
323
|
* Read the persisted company mapping for a repo identity key (gap 4).
|
|
@@ -193,43 +347,155 @@ export function setRepoCompany(key, mapping, deps) {
|
|
|
193
347
|
throw new WorkContextError("InvalidCompanySlug", `Invalid company slug: ${mapping.slug}`);
|
|
194
348
|
}
|
|
195
349
|
const now = deps.now ?? (() => new Date());
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
350
|
+
return withDeviceConfigLock(deps.root, () => {
|
|
351
|
+
const prior = readDeviceConfig({ root: deps.root });
|
|
352
|
+
const entry = { slug, updatedAt: now().toISOString() };
|
|
353
|
+
const uid = mapping.uid?.trim();
|
|
354
|
+
if (uid)
|
|
355
|
+
entry.uid = uid;
|
|
356
|
+
const nextMap = { ...(prior.repoCompanyMap ?? {}), [trimmed]: entry };
|
|
357
|
+
const next = {
|
|
358
|
+
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
359
|
+
defaultCompany: prior.defaultCompany ?? null,
|
|
360
|
+
migrationCapability: prior.migrationCapability ?? null,
|
|
361
|
+
repoCompanyMap: nextMap,
|
|
362
|
+
defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
|
|
363
|
+
repairHeldWithDefault: prior.repairHeldWithDefault === true,
|
|
364
|
+
updatedAt: now().toISOString(),
|
|
365
|
+
};
|
|
366
|
+
writeDeviceConfig({ root: deps.root }, next);
|
|
367
|
+
return next;
|
|
368
|
+
});
|
|
211
369
|
}
|
|
212
370
|
/** Persist the last memberships-wide migration capability probe (US-017B). */
|
|
213
371
|
export function recordMigrationCapabilitySnapshot(snapshot, deps) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
372
|
+
return withDeviceConfigLock(deps.root, () => {
|
|
373
|
+
const prior = readDeviceConfig(deps);
|
|
374
|
+
const now = deps.now ?? (() => new Date());
|
|
375
|
+
const next = {
|
|
376
|
+
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
377
|
+
defaultCompany: prior.defaultCompany ?? null,
|
|
378
|
+
migrationCapability: {
|
|
379
|
+
unlocked: snapshot.unlocked,
|
|
380
|
+
offline: snapshot.offline,
|
|
381
|
+
checkedAt: snapshot.checkedAt,
|
|
382
|
+
companies: snapshot.companies.map((c) => ({
|
|
383
|
+
companyUid: c.companyUid,
|
|
384
|
+
migration: c.migration,
|
|
385
|
+
})),
|
|
386
|
+
},
|
|
387
|
+
repoCompanyMap: prior.repoCompanyMap ?? null,
|
|
388
|
+
defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
|
|
389
|
+
repairHeldWithDefault: prior.repairHeldWithDefault === true,
|
|
390
|
+
updatedAt: now().toISOString(),
|
|
391
|
+
};
|
|
392
|
+
writeDeviceConfig({ root: deps.root }, next);
|
|
393
|
+
return next;
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
function sanitizeMembershipSnapshot(raw) {
|
|
397
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
398
|
+
return null;
|
|
399
|
+
const value = raw;
|
|
400
|
+
if (typeof value.checkedAt !== "string" || !Array.isArray(value.memberships))
|
|
401
|
+
return null;
|
|
402
|
+
const memberships = value.memberships.flatMap((entry) => {
|
|
403
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
404
|
+
return [];
|
|
405
|
+
const member = entry;
|
|
406
|
+
const slug = typeof member.slug === "string" ? member.slug.trim() : "";
|
|
407
|
+
const uid = typeof member.uid === "string" ? member.uid.trim() : "";
|
|
408
|
+
return slug && uid ? [{ slug, uid }] : [];
|
|
409
|
+
});
|
|
410
|
+
return { checkedAt: value.checkedAt, memberships };
|
|
411
|
+
}
|
|
412
|
+
export function recordDefaultCompanyMemberships(snapshot, deps) {
|
|
413
|
+
return withDeviceConfigLock(deps.root, () => {
|
|
414
|
+
const prior = readDeviceConfig(deps);
|
|
415
|
+
const now = deps.now ?? (() => new Date());
|
|
416
|
+
const next = {
|
|
417
|
+
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
418
|
+
defaultCompany: prior.defaultCompany ?? null,
|
|
419
|
+
migrationCapability: prior.migrationCapability ?? null,
|
|
420
|
+
repoCompanyMap: prior.repoCompanyMap ?? null,
|
|
421
|
+
defaultCompanyMemberships: {
|
|
422
|
+
checkedAt: snapshot.checkedAt,
|
|
423
|
+
memberships: snapshot.memberships.map((membership) => ({ ...membership })),
|
|
424
|
+
},
|
|
425
|
+
repairHeldWithDefault: prior.repairHeldWithDefault === true,
|
|
426
|
+
updatedAt: now().toISOString(),
|
|
427
|
+
};
|
|
428
|
+
writeDeviceConfig({ root: deps.root }, next);
|
|
429
|
+
return next;
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
/** Drop cached membership evidence immediately after an authoritative denial. */
|
|
433
|
+
export function invalidateDefaultCompanyMemberships(deps) {
|
|
434
|
+
return withDeviceConfigLock(deps.root, () => {
|
|
435
|
+
const prior = readDeviceConfig(deps);
|
|
436
|
+
const now = deps.now ?? (() => new Date());
|
|
437
|
+
const next = {
|
|
438
|
+
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
439
|
+
defaultCompany: prior.defaultCompany ?? null,
|
|
440
|
+
migrationCapability: prior.migrationCapability ?? null,
|
|
441
|
+
repoCompanyMap: prior.repoCompanyMap ?? null,
|
|
442
|
+
defaultCompanyMemberships: null,
|
|
443
|
+
repairHeldWithDefault: prior.repairHeldWithDefault === true,
|
|
444
|
+
updatedAt: now().toISOString(),
|
|
445
|
+
};
|
|
446
|
+
writeDeviceConfig({ root: deps.root }, next);
|
|
447
|
+
return next;
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
/** Persist a default only after the complete active-membership list verified it. */
|
|
451
|
+
export function setAutoDefaultCompany(membership, deps) {
|
|
452
|
+
return withDeviceConfigLock(deps.root, () => {
|
|
453
|
+
const prior = readDeviceConfig(deps);
|
|
454
|
+
// Activation may have fetched memberships while another process cleared or
|
|
455
|
+
// changed this preference. Do not let that stale discovery overwrite the
|
|
456
|
+
// newer durable choice.
|
|
457
|
+
if (prior.defaultCompany?.source === "disabled" ||
|
|
458
|
+
prior.defaultCompany?.enabled ||
|
|
459
|
+
(deps.expectedUpdatedAt !== undefined &&
|
|
460
|
+
prior.updatedAt !== deps.expectedUpdatedAt)) {
|
|
461
|
+
return prior;
|
|
462
|
+
}
|
|
463
|
+
deps.afterCompareAndSwap?.();
|
|
464
|
+
const now = deps.now ?? (() => new Date());
|
|
465
|
+
const next = {
|
|
466
|
+
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
467
|
+
defaultCompany: {
|
|
468
|
+
slug: membership.slug,
|
|
469
|
+
uid: membership.uid,
|
|
470
|
+
enabled: true,
|
|
471
|
+
source: "auto_single",
|
|
472
|
+
},
|
|
473
|
+
migrationCapability: prior.migrationCapability ?? null,
|
|
474
|
+
repoCompanyMap: prior.repoCompanyMap ?? null,
|
|
475
|
+
defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
|
|
476
|
+
repairHeldWithDefault: prior.repairHeldWithDefault === true,
|
|
477
|
+
updatedAt: now().toISOString(),
|
|
478
|
+
};
|
|
479
|
+
writeDeviceConfig({ root: deps.root }, next);
|
|
480
|
+
return next;
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
export function setRepairHeldWithDefault(enabled, deps) {
|
|
484
|
+
return withDeviceConfigLock(deps.root, () => {
|
|
485
|
+
const prior = readDeviceConfig(deps);
|
|
486
|
+
const now = deps.now ?? (() => new Date());
|
|
487
|
+
const next = {
|
|
488
|
+
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
489
|
+
defaultCompany: prior.defaultCompany ?? null,
|
|
490
|
+
migrationCapability: prior.migrationCapability ?? null,
|
|
491
|
+
repoCompanyMap: prior.repoCompanyMap ?? null,
|
|
492
|
+
defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
|
|
493
|
+
repairHeldWithDefault: enabled,
|
|
494
|
+
updatedAt: now().toISOString(),
|
|
495
|
+
};
|
|
496
|
+
writeDeviceConfig({ root: deps.root }, next);
|
|
497
|
+
return next;
|
|
498
|
+
});
|
|
233
499
|
}
|
|
234
500
|
/** Convenience: build deps from HOME / injectable root. */
|
|
235
501
|
export function deviceConfigDepsFromHome(home, extras = {}) {
|
|
@@ -54,6 +54,8 @@ export interface ContextResult {
|
|
|
54
54
|
lifecycle: LifecycleState;
|
|
55
55
|
sessionId: string;
|
|
56
56
|
companyUid?: string;
|
|
57
|
+
/** Resolved company slug; paired with companyUid for local hook consumers. */
|
|
58
|
+
companySlug?: string;
|
|
57
59
|
projectId?: string;
|
|
58
60
|
taskId?: string;
|
|
59
61
|
bindingEpisodeId?: string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-default activation. A device may infer a default only from the caller's
|
|
3
|
+
* complete, verified active-membership list; it never infers a tenant from cwd.
|
|
4
|
+
*/
|
|
5
|
+
import { type DefaultCompanyMembership } from "./config.js";
|
|
6
|
+
export declare const DEFAULT_COMPANY_MEMBERSHIP_TTL_MS: number;
|
|
7
|
+
export interface ActiveMembership {
|
|
8
|
+
companySlug: string;
|
|
9
|
+
companyUid: string;
|
|
10
|
+
}
|
|
11
|
+
export type DefaultCompanyActivation = {
|
|
12
|
+
status: "existing";
|
|
13
|
+
memberships: DefaultCompanyMembership[];
|
|
14
|
+
} | {
|
|
15
|
+
status: "auto_single";
|
|
16
|
+
memberships: DefaultCompanyMembership[];
|
|
17
|
+
} | {
|
|
18
|
+
status: "needs_choice";
|
|
19
|
+
memberships: DefaultCompanyMembership[];
|
|
20
|
+
} | {
|
|
21
|
+
status: "disabled";
|
|
22
|
+
memberships: DefaultCompanyMembership[];
|
|
23
|
+
} | {
|
|
24
|
+
status: "identity_file";
|
|
25
|
+
memberships: [];
|
|
26
|
+
} | {
|
|
27
|
+
status: "unavailable";
|
|
28
|
+
memberships: [];
|
|
29
|
+
};
|
|
30
|
+
export interface EnsureDefaultCompanyDeps {
|
|
31
|
+
root: string;
|
|
32
|
+
env?: NodeJS.ProcessEnv;
|
|
33
|
+
listMemberships: () => Promise<ActiveMembership[]>;
|
|
34
|
+
now?: () => Date;
|
|
35
|
+
log?: (message: string) => void;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Populate a blank device from one active membership, or expose the choices for
|
|
39
|
+
* an interactive caller. Identity-file boxes and explicitly disabled devices do
|
|
40
|
+
* not query memberships because a user/default must never override either.
|
|
41
|
+
*/
|
|
42
|
+
export declare function ensureDefaultCompany(deps: EnsureDefaultCompanyDeps): Promise<DefaultCompanyActivation>;
|
|
43
|
+
//# sourceMappingURL=default-company.d.ts.map
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-default activation. A device may infer a default only from the caller's
|
|
3
|
+
* complete, verified active-membership list; it never infers a tenant from cwd.
|
|
4
|
+
*/
|
|
5
|
+
import { hasAgentIdentityFile } from "./company.js";
|
|
6
|
+
import { isDefaultCompanyDisabled, readDeviceConfig, recordDefaultCompanyMemberships, setAutoDefaultCompany, } from "./config.js";
|
|
7
|
+
export const DEFAULT_COMPANY_MEMBERSHIP_TTL_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
function normalizeMemberships(rows) {
|
|
9
|
+
const seen = new Set();
|
|
10
|
+
return rows.flatMap((row) => {
|
|
11
|
+
const slug = row.companySlug?.trim();
|
|
12
|
+
const uid = row.companyUid?.trim();
|
|
13
|
+
if (!slug || !uid || seen.has(uid))
|
|
14
|
+
return [];
|
|
15
|
+
seen.add(uid);
|
|
16
|
+
return [{ slug, uid }];
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
function cachedMemberships(root, now) {
|
|
20
|
+
const snapshot = readDeviceConfig({ root }).defaultCompanyMemberships;
|
|
21
|
+
if (!snapshot)
|
|
22
|
+
return null;
|
|
23
|
+
const checkedAt = Date.parse(snapshot.checkedAt);
|
|
24
|
+
if (!Number.isFinite(checkedAt) || now.getTime() - checkedAt > DEFAULT_COMPANY_MEMBERSHIP_TTL_MS) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return snapshot.memberships;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Populate a blank device from one active membership, or expose the choices for
|
|
31
|
+
* an interactive caller. Identity-file boxes and explicitly disabled devices do
|
|
32
|
+
* not query memberships because a user/default must never override either.
|
|
33
|
+
*/
|
|
34
|
+
export async function ensureDefaultCompany(deps) {
|
|
35
|
+
const env = deps.env ?? process.env;
|
|
36
|
+
if (hasAgentIdentityFile(env))
|
|
37
|
+
return { status: "identity_file", memberships: [] };
|
|
38
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
39
|
+
const configBeforeDiscovery = readDeviceConfig({ root: deps.root });
|
|
40
|
+
const cached = cachedMemberships(deps.root, now) ?? [];
|
|
41
|
+
if (isDefaultCompanyDisabled({ root: deps.root })) {
|
|
42
|
+
return { status: "disabled", memberships: cached };
|
|
43
|
+
}
|
|
44
|
+
let memberships = cachedMemberships(deps.root, now);
|
|
45
|
+
if (!memberships) {
|
|
46
|
+
try {
|
|
47
|
+
memberships = normalizeMemberships(await deps.listMemberships());
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return { status: "unavailable", memberships: [] };
|
|
51
|
+
}
|
|
52
|
+
// Detect a clear or selection made while the network request was pending.
|
|
53
|
+
// A stale discovery may update the cache, but must not make a default.
|
|
54
|
+
const recorded = recordDefaultCompanyMemberships({ checkedAt: now.toISOString(), memberships }, { root: deps.root, now: deps.now });
|
|
55
|
+
if (recorded.defaultCompany?.source === "disabled") {
|
|
56
|
+
return { status: "disabled", memberships };
|
|
57
|
+
}
|
|
58
|
+
if (recorded.defaultCompany?.enabled) {
|
|
59
|
+
return { status: "existing", memberships };
|
|
60
|
+
}
|
|
61
|
+
configBeforeDiscovery.updatedAt = recorded.updatedAt;
|
|
62
|
+
}
|
|
63
|
+
if (memberships.length === 1) {
|
|
64
|
+
const selected = memberships[0];
|
|
65
|
+
const next = setAutoDefaultCompany(selected, {
|
|
66
|
+
root: deps.root,
|
|
67
|
+
now: deps.now,
|
|
68
|
+
expectedUpdatedAt: configBeforeDiscovery.updatedAt,
|
|
69
|
+
});
|
|
70
|
+
if (next.defaultCompany?.source === "disabled") {
|
|
71
|
+
return { status: "disabled", memberships };
|
|
72
|
+
}
|
|
73
|
+
if (next.defaultCompany?.source !== "auto_single") {
|
|
74
|
+
return { status: "existing", memberships };
|
|
75
|
+
}
|
|
76
|
+
deps.log?.(`Default company set automatically to ${selected.slug}.`);
|
|
77
|
+
return { status: "auto_single", memberships };
|
|
78
|
+
}
|
|
79
|
+
if (memberships.length > 1)
|
|
80
|
+
return { status: "needs_choice", memberships };
|
|
81
|
+
return { status: "unavailable", memberships: [] };
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=default-company.js.map
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import type { DeliveryState } from "./contract.js";
|
|
6
6
|
import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
|
|
7
7
|
/** Fields allowed on a durable outbox operation (privacy allowlist). */
|
|
8
|
-
export declare const OUTBOX_ALLOWLIST: readonly ["contractVersion", "operationId", "clientOperationId", "sessionId", "harness", "companyUid", "companySlug", "projectId", "taskId", "kind", "digest", "delivery", "createdAt", "updatedAt", "attemptCount", "lastErrorCode", "receiptId", "destinationCompanyUid", "destinationCompanySlug", "nextAttemptAt"];
|
|
8
|
+
export declare const OUTBOX_ALLOWLIST: readonly ["contractVersion", "operationId", "clientOperationId", "sessionId", "harness", "companyUid", "companySlug", "projectId", "taskId", "companyConfidence", "kind", "digest", "delivery", "createdAt", "updatedAt", "attemptCount", "lastErrorCode", "receiptId", "destinationCompanyUid", "destinationCompanySlug", "nextAttemptAt"];
|
|
9
9
|
/** Base delay for outbox retry backoff (attempt 1 → 30s). */
|
|
10
10
|
export declare const OUTBOX_RETRY_BASE_MS = 30000;
|
|
11
11
|
/** Cap for outbox retry backoff (6 hours). */
|
|
@@ -25,6 +25,8 @@ export interface OutboxOperation {
|
|
|
25
25
|
companySlug?: string;
|
|
26
26
|
projectId?: string;
|
|
27
27
|
taskId?: string;
|
|
28
|
+
/** Durable provenance for weak device-default bindings. */
|
|
29
|
+
companyConfidence?: "device_default";
|
|
28
30
|
kind: OutboxKind;
|
|
29
31
|
digest: string;
|
|
30
32
|
delivery: DeliveryState;
|
|
@@ -47,6 +49,7 @@ export interface OutboxEnqueueInput {
|
|
|
47
49
|
companySlug?: string;
|
|
48
50
|
projectId?: string;
|
|
49
51
|
taskId?: string;
|
|
52
|
+
companyConfidence?: "device_default";
|
|
50
53
|
kind?: OutboxKind;
|
|
51
54
|
destinationCompanyUid?: string;
|
|
52
55
|
destinationCompanySlug?: string;
|
|
@@ -62,6 +65,7 @@ export declare function digestOperation(parts: {
|
|
|
62
65
|
companySlug?: string;
|
|
63
66
|
projectId?: string;
|
|
64
67
|
taskId?: string;
|
|
68
|
+
companyConfidence?: "device_default";
|
|
65
69
|
kind: OutboxKind;
|
|
66
70
|
destinationCompanyUid?: string;
|
|
67
71
|
destinationCompanySlug?: string;
|