@indigoai-us/hq-cli 5.345.119 → 5.345.120
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 +9 -0
- package/assets/scaffold/core/scripts/session-journal.sh +67 -5
- package/dist/command-catalog.generated.d.ts +4 -0
- package/dist/command-catalog.generated.js +5 -0
- package/dist/commands/core-session-journal-native.js +142 -12
- package/dist/commands/dm.js +23 -3
- package/dist/utils/vault-api.d.ts +10 -0
- package/dist/utils/vault-api.js +27 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,11 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.120] — 2026-10-13
|
|
6
|
+
- `hq dm forward --company <slug|cmp_id>` names the company a person or agent
|
|
7
|
+
destination belongs to. The CLI sends it as `companyUid`, so the server runs
|
|
8
|
+
its destination-side cross-company check. Without it the destination is
|
|
9
|
+
treated as personal. `--company` is refused for channel destinations, which
|
|
10
|
+
use the channel's own company.
|
|
11
|
+
|
|
5
12
|
## [5.345.119] — 2026-10-13
|
|
6
13
|
- The checkpoint sibling no longer falls back to Codex. `--backend auto` now picks only Claude or grok. Codex runs only with `--backend codex` or the new `HQ_CHECKPOINT_BACKEND=codex` setting (process environment or the HQ settings file env block). A sibling run that left no report stops counting as a crash after 6 hours, so one transient failure no longer pins the choice. When no allowed backend is available the sibling is skipped with a log line.
|
|
7
14
|
- Add native TypeScript hq core pipeline conductor and driver commands with the bundled shell companions as the flag-off fallback.
|
|
8
15
|
- Fix native policy retirement reports for partly stale references, accept shell-written newline-terminated trigger cooldown stamps, and resolve derive-trigger-facts session metadata from `HQ_ROOT` when the caller cwd differs, including repo detection for Windows path separators.
|
|
9
16
|
|
|
17
|
+
- `hq core session-journal tool-counter` now skips updates when its bounded lock retries are exhausted and reclaims stale counter locks like the shell implementation.
|
|
18
|
+
|
|
10
19
|
## [5.345.118] — 2026-10-13
|
|
11
20
|
|
|
12
21
|
- Remove the bundled worker-skill shell fallbacks. `hq core worker lint` and
|
|
@@ -128,12 +128,57 @@ resolve_session_key() {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
# Read-modify-write on the counter must not interleave across concurrent tasks.
|
|
131
|
+
counter_lock_is_aged() {
|
|
132
|
+
local lockdir="$1" lock_mtime now
|
|
133
|
+
lock_mtime="$(stat -c %Y "$lockdir" 2>/dev/null)" || lock_mtime="$(stat -f %m "$lockdir" 2>/dev/null)" || return 1
|
|
134
|
+
now="$(date -u +%s 2>/dev/null)" || return 1
|
|
135
|
+
case "$lock_mtime:$now" in *[!0-9:]*|:*|*:) return 1 ;; esac
|
|
136
|
+
[ "$now" -ge "$lock_mtime" ] && [ "$((now - lock_mtime))" -ge 60 ]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
counter_windows_pid_is_alive() {
|
|
140
|
+
local pid="$1" output
|
|
141
|
+
case "${OSTYPE:-}" in msys*|cygwin*|win32*) ;; *) return 1 ;; esac
|
|
142
|
+
case "$pid" in ''|*[!0-9]*) return 1 ;; esac
|
|
143
|
+
output="$(MSYS_NO_PATHCONV=1 tasklist.exe /FO CSV /NH /FI "PID eq $pid" 2>/dev/null)" || return 1
|
|
144
|
+
printf '%s\n' "$output" | tr -d '\r"' | cut -d, -f2 | grep -Fxq "$pid"
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
counter_lock_owner_is_alive() {
|
|
148
|
+
local lockdir="$1" owner_pid="$2" windows_pid
|
|
149
|
+
kill -0 "$owner_pid" 2>/dev/null && return 0
|
|
150
|
+
windows_pid="$(cat "$lockdir/winpid" 2>/dev/null || true)"
|
|
151
|
+
if [ -n "$windows_pid" ]; then
|
|
152
|
+
counter_windows_pid_is_alive "$windows_pid" && return 0
|
|
153
|
+
elif counter_windows_pid_is_alive "$owner_pid"; then
|
|
154
|
+
# Old native clients wrote only a Win32 process.pid to pid.
|
|
155
|
+
return 0
|
|
156
|
+
fi
|
|
157
|
+
return 1
|
|
158
|
+
}
|
|
159
|
+
|
|
131
160
|
reclaim_stale_counter_lock() {
|
|
132
|
-
local lockdir="$1" owner_pid current_owner claim stale_dir suffix=0
|
|
161
|
+
local lockdir="$1" owner_pid current_owner current_windows_pid windows_pid claim stale_dir suffix=0 pidless=0
|
|
133
162
|
owner_pid="$(cat "$lockdir/pid" 2>/dev/null || true)"
|
|
134
|
-
case "$owner_pid" in
|
|
135
|
-
|
|
136
|
-
|
|
163
|
+
case "$owner_pid" in
|
|
164
|
+
'')
|
|
165
|
+
windows_pid="$(cat "$lockdir/winpid" 2>/dev/null || true)"
|
|
166
|
+
if [ -n "$windows_pid" ]; then
|
|
167
|
+
counter_windows_pid_is_alive "$windows_pid" && return 1
|
|
168
|
+
fi
|
|
169
|
+
# The complete counter retry window is 5 seconds (250 x 20 ms). A
|
|
170
|
+
# PID-less lock is reclaimable after one minute, twelve retry windows,
|
|
171
|
+
# allowing for a creator paused between mkdir and writing pid. The stat
|
|
172
|
+
# fallback keeps this elapsed-time threshold consistent on GNU/BSD find.
|
|
173
|
+
counter_lock_is_aged "$lockdir" || return 1
|
|
174
|
+
pidless=1
|
|
175
|
+
;;
|
|
176
|
+
*[!0-9]*) return 1 ;;
|
|
177
|
+
*)
|
|
178
|
+
[ "$owner_pid" -gt 1 ] 2>/dev/null || return 1
|
|
179
|
+
counter_lock_owner_is_alive "$lockdir" "$owner_pid" && return 1
|
|
180
|
+
;;
|
|
181
|
+
esac
|
|
137
182
|
|
|
138
183
|
claim="${lockdir}.reclaim"
|
|
139
184
|
mkdir "$claim" 2>/dev/null || return 1
|
|
@@ -143,7 +188,19 @@ reclaim_stale_counter_lock() {
|
|
|
143
188
|
fi
|
|
144
189
|
|
|
145
190
|
current_owner="$(cat "$lockdir/pid" 2>/dev/null || true)"
|
|
146
|
-
|
|
191
|
+
current_windows_pid="$(cat "$lockdir/winpid" 2>/dev/null || true)"
|
|
192
|
+
if [ "$pidless" -eq 1 ]; then
|
|
193
|
+
# Recheck both identity and age after winning the claim, before renaming.
|
|
194
|
+
if [ -z "$current_owner" ] && [ "$current_windows_pid" = "$windows_pid" ] \
|
|
195
|
+
&& { [ -z "$current_windows_pid" ] || ! counter_windows_pid_is_alive "$current_windows_pid"; } \
|
|
196
|
+
&& counter_lock_is_aged "$lockdir"; then
|
|
197
|
+
owner_pid=""
|
|
198
|
+
else
|
|
199
|
+
owner_pid="changed"
|
|
200
|
+
fi
|
|
201
|
+
fi
|
|
202
|
+
if [ "$current_owner" = "$owner_pid" ] && [ "$current_windows_pid" = "${windows_pid:-$(cat "$lockdir/winpid" 2>/dev/null || true)}" ] \
|
|
203
|
+
&& { [ "$pidless" -eq 1 ] || ! counter_lock_owner_is_alive "$lockdir" "$owner_pid"; }; then
|
|
147
204
|
stale_dir="${lockdir}.stale.$$.$RANDOM"
|
|
148
205
|
while [ -e "$stale_dir" ]; do
|
|
149
206
|
suffix=$((suffix + 1))
|
|
@@ -167,6 +224,11 @@ with_counter_lock() {
|
|
|
167
224
|
while [ "$i" -lt "$tries" ]; do
|
|
168
225
|
if mkdir "$lockdir" 2>/dev/null; then
|
|
169
226
|
acquired=1
|
|
227
|
+
if [[ "${OSTYPE:-}" == msys* ]] || [[ "${OSTYPE:-}" == cygwin* ]]; then
|
|
228
|
+
local windows_pid
|
|
229
|
+
windows_pid="$(cat "/proc/$$/winpid" 2>/dev/null || true)"
|
|
230
|
+
case "$windows_pid" in ''|*[!0-9]*) ;; *) printf '%s\n' "$windows_pid" > "$lockdir/winpid" || { rm -rf "$lockdir" 2>/dev/null || true; return 0; } ;; esac
|
|
231
|
+
fi
|
|
170
232
|
if ! printf '%s\n' "$$" > "$lockdir/pid" 2>/dev/null; then
|
|
171
233
|
rm -rf "$lockdir" 2>/dev/null || true
|
|
172
234
|
return 0
|
|
@@ -3611,6 +3611,10 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
3611
3611
|
readonly flags: "--cross-company";
|
|
3612
3612
|
readonly description: "Confirm forwarding across company boundaries";
|
|
3613
3613
|
readonly hidden: false;
|
|
3614
|
+
}, {
|
|
3615
|
+
readonly flags: "--company <slug|cmp_id>";
|
|
3616
|
+
readonly description: "Company the person or agent destination belongs to, so cross-company rules apply";
|
|
3617
|
+
readonly hidden: false;
|
|
3614
3618
|
}];
|
|
3615
3619
|
readonly subcommands: readonly [];
|
|
3616
3620
|
}, {
|
|
@@ -4370,6 +4370,11 @@ export const COMMAND_CATALOG = [
|
|
|
4370
4370
|
"flags": "--cross-company",
|
|
4371
4371
|
"description": "Confirm forwarding across company boundaries",
|
|
4372
4372
|
"hidden": false
|
|
4373
|
+
},
|
|
4374
|
+
{
|
|
4375
|
+
"flags": "--company <slug|cmp_id>",
|
|
4376
|
+
"description": "Company the person or agent destination belongs to, so cross-company rules apply",
|
|
4377
|
+
"hidden": false
|
|
4373
4378
|
}
|
|
4374
4379
|
],
|
|
4375
4380
|
"subcommands": []
|
|
@@ -89,6 +89,18 @@ function processIsAlive(pid) {
|
|
|
89
89
|
return error.code !== "ESRCH";
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
+
function counterLockOwnerIsAlive(lock, pid) {
|
|
93
|
+
if (processIsAlive(pid))
|
|
94
|
+
return true;
|
|
95
|
+
if (process.platform !== "win32")
|
|
96
|
+
return false;
|
|
97
|
+
let windowsPid = 0;
|
|
98
|
+
try {
|
|
99
|
+
windowsPid = Number.parseInt(fs.readFileSync(path.join(lock, "winpid"), "utf8"), 10) || 0;
|
|
100
|
+
}
|
|
101
|
+
catch { /* older shell locks did not record the Win32 PID */ }
|
|
102
|
+
return windowsPid > 1 && processIsAlive(windowsPid);
|
|
103
|
+
}
|
|
92
104
|
function acquireJournalWriteLock(dir) {
|
|
93
105
|
const lock = path.join(dir, ".session-journal-write.lock");
|
|
94
106
|
const ownerFile = path.join(lock, "pid");
|
|
@@ -142,26 +154,144 @@ function releaseJournalWriteLock(lock) {
|
|
|
142
154
|
}
|
|
143
155
|
catch { /* shell lock cleanup is best-effort */ }
|
|
144
156
|
}
|
|
145
|
-
|
|
157
|
+
const COUNTER_LOCK_PIDLESS_STALE_MS = 60_000;
|
|
158
|
+
function isAgedPidlessCounterLock(lock, now) {
|
|
159
|
+
try {
|
|
160
|
+
return now().getTime() - fs.statSync(lock).mtimeMs >= COUNTER_LOCK_PIDLESS_STALE_MS;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function reclaimStaleCounterLock(lock, now) {
|
|
167
|
+
let rawOwner;
|
|
168
|
+
try {
|
|
169
|
+
rawOwner = fs.readFileSync(path.join(lock, "pid"), "utf8").trim();
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (error.code !== "ENOENT" || !isAgedPidlessCounterLock(lock, now))
|
|
173
|
+
return false;
|
|
174
|
+
rawOwner = "";
|
|
175
|
+
}
|
|
176
|
+
if (rawOwner !== "" && !/^\d+$/u.test(rawOwner))
|
|
177
|
+
return false;
|
|
178
|
+
let rawWindowsPid = "";
|
|
179
|
+
try {
|
|
180
|
+
rawWindowsPid = fs.readFileSync(path.join(lock, "winpid"), "utf8").trim();
|
|
181
|
+
}
|
|
182
|
+
catch { /* older shell locks did not record the Win32 PID */ }
|
|
183
|
+
if (rawWindowsPid !== "" && !/^\d+$/u.test(rawWindowsPid))
|
|
184
|
+
return false;
|
|
185
|
+
const owner = rawOwner ? Number.parseInt(rawOwner, 10) : 0;
|
|
186
|
+
const windowsOwner = rawWindowsPid ? Number.parseInt(rawWindowsPid, 10) : 0;
|
|
187
|
+
if (rawOwner && (owner <= 1 || counterLockOwnerIsAlive(lock, owner)))
|
|
188
|
+
return false;
|
|
189
|
+
if (!rawOwner && windowsOwner > 1 && processIsAlive(windowsOwner))
|
|
190
|
+
return false;
|
|
191
|
+
const claim = `${lock}.reclaim`;
|
|
192
|
+
try {
|
|
193
|
+
fs.mkdirSync(claim);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
fs.writeFileSync(path.join(claim, "pid"), `${process.pid}\n`, { flag: "wx" });
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
try {
|
|
203
|
+
fs.rmSync(claim, { recursive: true, force: true });
|
|
204
|
+
}
|
|
205
|
+
catch { /* best effort */ }
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
let currentRawOwner;
|
|
209
|
+
try {
|
|
210
|
+
currentRawOwner = fs.readFileSync(path.join(lock, "pid"), "utf8").trim();
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
currentRawOwner = "";
|
|
214
|
+
}
|
|
215
|
+
let currentRawWindowsPid;
|
|
216
|
+
try {
|
|
217
|
+
currentRawWindowsPid = fs.readFileSync(path.join(lock, "winpid"), "utf8").trim();
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
currentRawWindowsPid = "";
|
|
221
|
+
}
|
|
222
|
+
const sameOwner = currentRawOwner === rawOwner && currentRawWindowsPid === rawWindowsPid;
|
|
223
|
+
const stillStale = rawOwner
|
|
224
|
+
? sameOwner && !counterLockOwnerIsAlive(lock, owner)
|
|
225
|
+
: sameOwner && !(windowsOwner > 1 && processIsAlive(windowsOwner)) && isAgedPidlessCounterLock(lock, now);
|
|
226
|
+
if (stillStale) {
|
|
227
|
+
const stale = `${lock}.stale-${process.pid}-${Math.random().toString(16).slice(2)}`;
|
|
228
|
+
try {
|
|
229
|
+
fs.renameSync(lock, stale);
|
|
230
|
+
fs.rmSync(stale, { recursive: true, force: true });
|
|
231
|
+
fs.rmSync(claim, { recursive: true, force: true });
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
catch { /* another waiter reclaimed it */ }
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
fs.rmSync(claim, { recursive: true, force: true });
|
|
238
|
+
}
|
|
239
|
+
catch { /* best effort */ }
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
function withCounterLock(lock, env, writers, now, action) {
|
|
146
243
|
let i = 0;
|
|
147
244
|
let acquired = false;
|
|
148
|
-
|
|
245
|
+
let tries = env.HQ_JOURNAL_COUNTER_LOCK_TRIES ?? "250";
|
|
246
|
+
if (!/^\d+$/u.test(tries))
|
|
247
|
+
tries = "250";
|
|
248
|
+
const retryLimit = Number.parseInt(tries, 10);
|
|
249
|
+
while (i < retryLimit) {
|
|
149
250
|
try {
|
|
150
251
|
fs.mkdirSync(lock);
|
|
151
252
|
acquired = true;
|
|
253
|
+
try {
|
|
254
|
+
if (process.platform === "win32")
|
|
255
|
+
fs.writeFileSync(path.join(lock, "winpid"), `${process.pid}\n`, { flag: "wx" });
|
|
256
|
+
fs.writeFileSync(path.join(lock, "pid"), `${process.pid}\n`, { flag: "wx" });
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
try {
|
|
260
|
+
fs.rmSync(lock, { recursive: true, force: true });
|
|
261
|
+
}
|
|
262
|
+
catch { /* best effort */ }
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
break;
|
|
152
266
|
}
|
|
153
267
|
catch {
|
|
268
|
+
if (reclaimStaleCounterLock(lock, now))
|
|
269
|
+
continue;
|
|
154
270
|
i++;
|
|
155
|
-
if (i
|
|
156
|
-
|
|
157
|
-
sleepSync(20);
|
|
271
|
+
if (i < retryLimit)
|
|
272
|
+
sleepSync(20);
|
|
158
273
|
}
|
|
159
274
|
}
|
|
160
|
-
|
|
275
|
+
if (!acquired) {
|
|
276
|
+
writers.stderr("session-journal: tool-counter: lock remained busy; skipped update\n");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
161
279
|
try {
|
|
162
|
-
|
|
280
|
+
action();
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
let owner = 0;
|
|
284
|
+
try {
|
|
285
|
+
owner = Number.parseInt(fs.readFileSync(path.join(lock, "pid"), "utf8"), 10) || 0;
|
|
286
|
+
}
|
|
287
|
+
catch { /* lock disappeared */ }
|
|
288
|
+
if (owner === process.pid) {
|
|
289
|
+
try {
|
|
290
|
+
fs.rmSync(lock, { recursive: true, force: true });
|
|
291
|
+
}
|
|
292
|
+
catch { /* best effort */ }
|
|
293
|
+
}
|
|
163
294
|
}
|
|
164
|
-
catch { /* shell rmdir is best-effort */ }
|
|
165
295
|
}
|
|
166
296
|
function nextSequence(dir) {
|
|
167
297
|
let names = [];
|
|
@@ -205,12 +335,12 @@ function runToolCounter(root, action, sessionArg, env, now, writers) {
|
|
|
205
335
|
writers.stderr(`${sessionJournalAsset}: line ${line}: ${printableCounter}: Is a directory\n`);
|
|
206
336
|
};
|
|
207
337
|
if (action === "increment") {
|
|
208
|
-
withCounterLock(lock, () => {
|
|
338
|
+
withCounterLock(lock, env, writers, now, () => {
|
|
209
339
|
try {
|
|
210
340
|
fs.writeFileSync(counter, `${readCounter(counter) + 1}\n`);
|
|
211
341
|
}
|
|
212
342
|
catch (error) {
|
|
213
|
-
reportDirectoryWriteFailure(error,
|
|
343
|
+
reportDirectoryWriteFailure(error, 457);
|
|
214
344
|
}
|
|
215
345
|
});
|
|
216
346
|
}
|
|
@@ -218,12 +348,12 @@ function runToolCounter(root, action, sessionArg, env, now, writers) {
|
|
|
218
348
|
writers.stdout(`${readCounter(counter)}\n`);
|
|
219
349
|
}
|
|
220
350
|
else if (action === "reset") {
|
|
221
|
-
withCounterLock(lock, () => {
|
|
351
|
+
withCounterLock(lock, env, writers, now, () => {
|
|
222
352
|
try {
|
|
223
353
|
fs.writeFileSync(counter, "0\n");
|
|
224
354
|
}
|
|
225
355
|
catch (error) {
|
|
226
|
-
reportDirectoryWriteFailure(error,
|
|
356
|
+
reportDirectoryWriteFailure(error, 465);
|
|
227
357
|
}
|
|
228
358
|
});
|
|
229
359
|
}
|
package/dist/commands/dm.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
4
|
-
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
4
|
+
import { resolveDestinationCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
|
|
5
5
|
import { isMachineIdentity } from "@indigoai-us/hq-cloud";
|
|
6
6
|
import { peekIdToken } from "../utils/id-token.js";
|
|
7
7
|
import { EMAIL_PATTERN, RECIPIENT_UID_PATTERN } from "../lib/notify-recipient.js";
|
|
@@ -830,12 +830,20 @@ async function runDmForward(eventId, opts) {
|
|
|
830
830
|
console.error(chalk.red("--to accepts one recipient or channel, not a group recipient list."));
|
|
831
831
|
process.exit(1);
|
|
832
832
|
}
|
|
833
|
+
const destinationChannelName = parseChannelName(opts.to);
|
|
834
|
+
if (opts.company !== undefined && destinationChannelName !== null) {
|
|
835
|
+
console.error(chalk.red("--company applies to person or agent destinations; a channel uses its own company."));
|
|
836
|
+
process.exit(1);
|
|
837
|
+
}
|
|
838
|
+
if (opts.company !== undefined && !opts.company.trim()) {
|
|
839
|
+
console.error(chalk.red("--company requires a company slug or cmp_ ID."));
|
|
840
|
+
process.exit(1);
|
|
841
|
+
}
|
|
833
842
|
const note = opts.noteFile !== undefined
|
|
834
843
|
? readFileSync(opts.noteFile, "utf8")
|
|
835
844
|
: opts.note;
|
|
836
845
|
const token = await ensureCognitoToken();
|
|
837
846
|
const source = await resolveForwardSourceConversation(token, opts.from);
|
|
838
|
-
const destinationChannelName = parseChannelName(opts.to);
|
|
839
847
|
const fileAccess = opts.grantFiles ? "grant" : opts.omitFiles ? "omit" : undefined;
|
|
840
848
|
const shared = {
|
|
841
849
|
...(note !== undefined ? { body: note } : {}),
|
|
@@ -869,8 +877,19 @@ async function runDmForward(eventId, opts) {
|
|
|
869
877
|
console.error(chalk.red("--to must be a person email, person/agent UID, or channel name/ID."));
|
|
870
878
|
process.exit(1);
|
|
871
879
|
}
|
|
880
|
+
// The server runs the destination-side cross-company check only when the
|
|
881
|
+
// request names the destination company; without it the destination is
|
|
882
|
+
// treated as personal. Not defaulted from the source, which would make a
|
|
883
|
+
// forward into another company look same-company.
|
|
884
|
+
const destinationCompanyUid = opts.company !== undefined
|
|
885
|
+
? await resolveDestinationCompanyUid(token, opts.company.trim())
|
|
886
|
+
: undefined;
|
|
872
887
|
path = "/v1/notify/dm";
|
|
873
|
-
payload = {
|
|
888
|
+
payload = {
|
|
889
|
+
...recipient,
|
|
890
|
+
...shared,
|
|
891
|
+
...(destinationCompanyUid ? { companyUid: destinationCompanyUid } : {}),
|
|
892
|
+
};
|
|
874
893
|
destinationLabel = opts.to;
|
|
875
894
|
}
|
|
876
895
|
const res = await vaultApiFetch({ token, path, method: "POST", body: payload });
|
|
@@ -1529,6 +1548,7 @@ export function registerDmCommand(program) {
|
|
|
1529
1548
|
.option("--grant-files", "Grant the recipient access to files they cannot currently open")
|
|
1530
1549
|
.option("--omit-files", "Omit files the recipient cannot currently open")
|
|
1531
1550
|
.option("--cross-company", "Confirm forwarding across company boundaries")
|
|
1551
|
+
.option("--company <slug|cmp_id>", "Company the person or agent destination belongs to, so cross-company rules apply")
|
|
1532
1552
|
.action(async (eventId, opts) => {
|
|
1533
1553
|
await runDmForward(eventId, opts);
|
|
1534
1554
|
});
|
|
@@ -68,6 +68,16 @@ export declare function vaultApiFetchPublic(opts: {
|
|
|
68
68
|
}): Promise<Response>;
|
|
69
69
|
export declare function looksLikeCompanyUid(ref: string): boolean;
|
|
70
70
|
export declare function looksLikePersonVaultUid(ref: string): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Resolve a company that may not be the caller's own — the destination of a
|
|
73
|
+
* cross-company DM forward. `getCompanyUid` prefers the caller's namespace, so
|
|
74
|
+
* when the destination company shares a slug with one of the caller's
|
|
75
|
+
* companies it would silently return the caller's company. Here a `cmp_` uid
|
|
76
|
+
* resolves directly; a slug goes to the global lookup first, and a slug that
|
|
77
|
+
* matches more than one company is refused with the candidate uids instead of
|
|
78
|
+
* guessing. Any other global failure falls back to `getCompanyUid`.
|
|
79
|
+
*/
|
|
80
|
+
export declare function resolveDestinationCompanyUid(token: string, ref: string, baseUrl?: string): Promise<string>;
|
|
71
81
|
export declare function getCompanyUid(token: string, companySlug: string | undefined,
|
|
72
82
|
/**
|
|
73
83
|
* Control-plane origin for the lookups. Defaults to DEFAULT_VAULT_API_URL;
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -609,6 +609,33 @@ async function resolveCompanyUid(token, ref, baseUrl) {
|
|
|
609
609
|
const data = (await res.json());
|
|
610
610
|
return data.entity.uid;
|
|
611
611
|
}
|
|
612
|
+
/**
|
|
613
|
+
* Resolve a company that may not be the caller's own — the destination of a
|
|
614
|
+
* cross-company DM forward. `getCompanyUid` prefers the caller's namespace, so
|
|
615
|
+
* when the destination company shares a slug with one of the caller's
|
|
616
|
+
* companies it would silently return the caller's company. Here a `cmp_` uid
|
|
617
|
+
* resolves directly; a slug goes to the global lookup first, and a slug that
|
|
618
|
+
* matches more than one company is refused with the candidate uids instead of
|
|
619
|
+
* guessing. Any other global failure falls back to `getCompanyUid`.
|
|
620
|
+
*/
|
|
621
|
+
export async function resolveDestinationCompanyUid(token, ref, baseUrl) {
|
|
622
|
+
if (looksLikeCompanyUid(ref)) {
|
|
623
|
+
return resolveCompanyByUid(token, ref, baseUrl);
|
|
624
|
+
}
|
|
625
|
+
const res = await fetchGlobalCompanyBySlug(token, ref, baseUrl);
|
|
626
|
+
if (res.ok) {
|
|
627
|
+
const data = (await res.json());
|
|
628
|
+
return data.entity.uid;
|
|
629
|
+
}
|
|
630
|
+
raiseIfUnauthorized(res);
|
|
631
|
+
const body = (await res.json().catch(() => ({})));
|
|
632
|
+
if (res.status === 409 && Array.isArray(body.uids) && body.uids.length > 0) {
|
|
633
|
+
throw new CompanySelectionError(`Company slug '${ref}' matches ${body.uids.length} companies. ` +
|
|
634
|
+
`Re-run with --company <cmp_id> to name the destination company:\n` +
|
|
635
|
+
body.uids.map((u) => ` --company ${u}`).join('\n'));
|
|
636
|
+
}
|
|
637
|
+
return getCompanyUid(token, ref, baseUrl);
|
|
638
|
+
}
|
|
612
639
|
async function resolveCompanyFromMemberships(token, baseUrl) {
|
|
613
640
|
const res = await vaultApiFetch({
|
|
614
641
|
token,
|