@qoniai/qoni 0.7.0 → 0.9.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 +126 -3
- package/README.md +146 -71
- package/README.zh-CN.md +98 -44
- package/dist/index.cjs +395 -61
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +176 -44
- package/dist/index.d.ts +176 -44
- package/dist/index.js +395 -61
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/skills/qoni-sdk/SKILL.md +42 -20
package/dist/index.cjs
CHANGED
|
@@ -23,7 +23,7 @@ __export(index_exports, {
|
|
|
23
23
|
ActionKinds: () => ActionKinds,
|
|
24
24
|
InteractionHandle: () => InteractionHandle,
|
|
25
25
|
InteractionStatuses: () => InteractionStatuses,
|
|
26
|
-
InteractionTypes: () =>
|
|
26
|
+
InteractionTypes: () => InteractionTypes2,
|
|
27
27
|
MonitorHandle: () => MonitorHandle,
|
|
28
28
|
Qoni: () => Qoni,
|
|
29
29
|
QoniAuthError: () => QoniAuthError,
|
|
@@ -566,8 +566,22 @@ var QoniScopes = {
|
|
|
566
566
|
// interaction). Not part of any product bundle: the AccessKey policy must
|
|
567
567
|
// allow it, and the delegation must request it explicitly.
|
|
568
568
|
SITE_LOGIN_REQUEST: "webagent.site_login:request",
|
|
569
|
-
// Persist that login into the browser profile (`
|
|
570
|
-
|
|
569
|
+
// Persist that login into the browser profile (`InteractionHandle.confirmSignedIn()`
|
|
570
|
+
// after `openLogin()`).
|
|
571
|
+
SITE_LOGIN_CONFIRM: "webagent.site_login:confirm",
|
|
572
|
+
// The delegating user's own data and the Agent's own mailbox (Personal
|
|
573
|
+
// Agent guide). Not part of any product bundle: request them explicitly;
|
|
574
|
+
// the AccessKey policy must allow them.
|
|
575
|
+
// Read the user's Profile (name, phone, shipping address, card brand + last 4).
|
|
576
|
+
USER_PROFILE_READ: "user.profile:read",
|
|
577
|
+
// Write answers back into the user's Profile.
|
|
578
|
+
USER_PROFILE_WRITE: "user.profile:write",
|
|
579
|
+
// Use the user's saved payment card once the user approves a payment.
|
|
580
|
+
USER_PAYMENT_USE: "user.payment:use",
|
|
581
|
+
// Read mail received by the Agent's own mailbox (for example verification codes).
|
|
582
|
+
AGENT_MAIL_READ: "agent.mail:read",
|
|
583
|
+
// Send mail from the Agent's own mailbox.
|
|
584
|
+
AGENT_MAIL_SEND: "agent.mail:send"
|
|
571
585
|
};
|
|
572
586
|
var QoniProductScopes = {
|
|
573
587
|
doAnything: [
|
|
@@ -620,6 +634,15 @@ var QoniScopeBundles = {
|
|
|
620
634
|
};
|
|
621
635
|
|
|
622
636
|
// src/interactions.ts
|
|
637
|
+
var InteractionTypes2 = {
|
|
638
|
+
SiteLogin: "site_login",
|
|
639
|
+
TakeControl: "take_control",
|
|
640
|
+
AskUser: "ask_user",
|
|
641
|
+
Confirmation: "confirmation",
|
|
642
|
+
Wait: "wait",
|
|
643
|
+
/** @deprecated `clarification` is presented as `ask_user`; use `InteractionTypes.AskUser`. */
|
|
644
|
+
Clarification: "ask_user"
|
|
645
|
+
};
|
|
623
646
|
function asString2(value) {
|
|
624
647
|
return typeof value === "string" && value ? value : void 0;
|
|
625
648
|
}
|
|
@@ -648,6 +671,49 @@ function normalizeEvidence(c) {
|
|
|
648
671
|
const artifactId = asString2(c.evidenceArtifactId) ?? (isJsonObject(c.evidence) ? asString2(c.evidence.artifactId) : void 0);
|
|
649
672
|
return artifactId ? { artifactId } : void 0;
|
|
650
673
|
}
|
|
674
|
+
var ANSWER_TYPES = /* @__PURE__ */ new Set([
|
|
675
|
+
"text",
|
|
676
|
+
"number",
|
|
677
|
+
"boolean",
|
|
678
|
+
"single_choice",
|
|
679
|
+
"multiple_choice"
|
|
680
|
+
]);
|
|
681
|
+
function normalizeOptions(value) {
|
|
682
|
+
if (!Array.isArray(value)) return [];
|
|
683
|
+
return value.flatMap((raw) => {
|
|
684
|
+
if (typeof raw === "string") return [{ value: raw, label: raw }];
|
|
685
|
+
if (!isJsonObject(raw) || typeof raw.value !== "string") return [];
|
|
686
|
+
return [{ value: raw.value, label: typeof raw.label === "string" ? raw.label : raw.value }];
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
function isAnswerType(value) {
|
|
690
|
+
return value !== void 0 && ANSWER_TYPES.has(value);
|
|
691
|
+
}
|
|
692
|
+
function askUserPayload(c) {
|
|
693
|
+
const options = normalizeOptions(c.options);
|
|
694
|
+
const native = asString2(c.answerType);
|
|
695
|
+
const hint = asString2(c.inputType);
|
|
696
|
+
let answerType;
|
|
697
|
+
if (isAnswerType(native)) answerType = native;
|
|
698
|
+
else if (isAnswerType(hint) && hint !== "single_choice" && hint !== "multiple_choice") answerType = hint;
|
|
699
|
+
else answerType = options.length > 0 ? hint === "multiple_choice" ? "multiple_choice" : "single_choice" : "text";
|
|
700
|
+
const payload = { question: asString2(c.question) ?? "", answerType };
|
|
701
|
+
if (options.length > 0) payload.options = options;
|
|
702
|
+
return payload;
|
|
703
|
+
}
|
|
704
|
+
var BASE_FIELDS = /* @__PURE__ */ new Set([
|
|
705
|
+
"id",
|
|
706
|
+
"type",
|
|
707
|
+
"status",
|
|
708
|
+
"createdAt",
|
|
709
|
+
"resolvedAt",
|
|
710
|
+
"expiresAt",
|
|
711
|
+
"title",
|
|
712
|
+
"prompt",
|
|
713
|
+
"evidence",
|
|
714
|
+
"evidenceArtifactId",
|
|
715
|
+
"actions"
|
|
716
|
+
]);
|
|
651
717
|
function shapePayload(type, c) {
|
|
652
718
|
switch (type) {
|
|
653
719
|
case "site_login": {
|
|
@@ -664,14 +730,10 @@ function shapePayload(type, c) {
|
|
|
664
730
|
const payload = { sites };
|
|
665
731
|
const monitorId = asString2(c.monitorId);
|
|
666
732
|
if (monitorId) payload.monitorId = monitorId;
|
|
667
|
-
return payload;
|
|
668
|
-
}
|
|
669
|
-
case "clarification": {
|
|
670
|
-
const payload = { question: asString2(c.question) ?? "" };
|
|
671
|
-
const inputType = asString2(c.inputType);
|
|
672
|
-
if (inputType) payload.inputType = inputType;
|
|
673
|
-
return payload;
|
|
733
|
+
return { ...payload };
|
|
674
734
|
}
|
|
735
|
+
case "ask_user":
|
|
736
|
+
return { ...askUserPayload(c) };
|
|
675
737
|
case "confirmation":
|
|
676
738
|
return { summary: asString2(c.summary) ?? "" };
|
|
677
739
|
case "take_control": {
|
|
@@ -680,7 +742,7 @@ function shapePayload(type, c) {
|
|
|
680
742
|
const reason = asString2(c.reason);
|
|
681
743
|
if (surface) payload.surface = surface;
|
|
682
744
|
if (reason) payload.reason = reason;
|
|
683
|
-
return payload;
|
|
745
|
+
return { ...payload };
|
|
684
746
|
}
|
|
685
747
|
case "wait": {
|
|
686
748
|
const payload = {
|
|
@@ -690,14 +752,20 @@ function shapePayload(type, c) {
|
|
|
690
752
|
const retryable = asBool(c.retryable);
|
|
691
753
|
if (until) payload.until = until;
|
|
692
754
|
if (retryable !== void 0) payload.retryable = retryable;
|
|
755
|
+
return { ...payload };
|
|
756
|
+
}
|
|
757
|
+
default: {
|
|
758
|
+
const payload = {};
|
|
759
|
+
for (const [key, value] of Object.entries(c)) if (!BASE_FIELDS.has(key)) payload[key] = value;
|
|
693
760
|
return payload;
|
|
694
761
|
}
|
|
695
762
|
}
|
|
696
763
|
}
|
|
697
764
|
function normalizeInteraction(payload) {
|
|
698
765
|
const c = camelizeRecord(payload);
|
|
699
|
-
const
|
|
700
|
-
if (!
|
|
766
|
+
const wireType = asString2(c.type);
|
|
767
|
+
if (!wireType) return null;
|
|
768
|
+
const type = wireType === "clarification" ? "ask_user" : wireType;
|
|
701
769
|
const interaction = {
|
|
702
770
|
id: asString2(c.id) ?? "",
|
|
703
771
|
type,
|
|
@@ -717,15 +785,109 @@ function normalizeInteraction(payload) {
|
|
|
717
785
|
if (evidence) interaction.evidence = evidence;
|
|
718
786
|
return interaction;
|
|
719
787
|
}
|
|
788
|
+
var SETTLED_INTERACTION_STATUSES = /* @__PURE__ */ new Set(["resolved", "expired", "canceled"]);
|
|
789
|
+
function isSettlingShell(next) {
|
|
790
|
+
if (!SETTLED_INTERACTION_STATUSES.has(next.status) || next.actions.length > 0) return false;
|
|
791
|
+
switch (next.type) {
|
|
792
|
+
case "ask_user":
|
|
793
|
+
return next.payload.question === "";
|
|
794
|
+
case "site_login":
|
|
795
|
+
return next.payload.sites.every((site) => !site.siteId);
|
|
796
|
+
case "confirmation":
|
|
797
|
+
return next.payload.summary === "";
|
|
798
|
+
default:
|
|
799
|
+
return false;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
function foldInteraction(previous, next) {
|
|
803
|
+
if (!previous || previous.id !== next.id || !isSettlingShell(next)) return next;
|
|
804
|
+
const folded = { ...previous, status: next.status, actions: next.actions };
|
|
805
|
+
if (next.resolvedAt) folded.resolvedAt = next.resolvedAt;
|
|
806
|
+
return folded;
|
|
807
|
+
}
|
|
720
808
|
function interactionActionScopes(action, productManageScope) {
|
|
721
809
|
return action.kind === ActionKinds.openLogin ? [QoniScopes.SITE_LOGIN_REQUEST] : [productManageScope];
|
|
722
810
|
}
|
|
811
|
+
function loginSlotIdle(slot) {
|
|
812
|
+
return slot.inflight === 0 && (!slot.session || slot.ended === true || slot.session.completed === true);
|
|
813
|
+
}
|
|
814
|
+
function markLoginEnded(store, interaction) {
|
|
815
|
+
if (!SETTLED_INTERACTION_STATUSES.has(interaction.status)) return;
|
|
816
|
+
const slot = store.get(interaction.id);
|
|
817
|
+
if (slot) slot.ended = true;
|
|
818
|
+
}
|
|
819
|
+
var SAVE_RETRY_PROBES = /* @__PURE__ */ new Set(["release_failed"]);
|
|
820
|
+
function isNoPendingLogin(err) {
|
|
821
|
+
return err instanceof QoniError && err.status === 409 && err.code === "no_pending_login";
|
|
822
|
+
}
|
|
823
|
+
var INTERACTION_MEMORY_LIMIT = 256;
|
|
824
|
+
function rememberBounded(map, key, value, limit = INTERACTION_MEMORY_LIMIT, evictable = () => true) {
|
|
825
|
+
map.delete(key);
|
|
826
|
+
map.set(key, value);
|
|
827
|
+
if (map.size <= limit) return;
|
|
828
|
+
for (const [candidate, entry] of map) {
|
|
829
|
+
if (map.size <= limit) break;
|
|
830
|
+
if (candidate !== key && evictable(entry)) map.delete(candidate);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
function interactionSettled(interaction) {
|
|
834
|
+
return SETTLED_INTERACTION_STATUSES.has(interaction.status);
|
|
835
|
+
}
|
|
836
|
+
function controlSession(response) {
|
|
837
|
+
const raw = isJsonObject(response) ? response : {};
|
|
838
|
+
const fields = camelizeRecord(raw);
|
|
839
|
+
const session = { liveUrl: asString2(fields.standaloneControlUrl) ?? asString2(fields.liveUrl) ?? "", raw };
|
|
840
|
+
const expiresAt = asString2(fields.controlTokenExpiresAt) ?? asString2(fields.expiresAt);
|
|
841
|
+
const status = asString2(fields.status);
|
|
842
|
+
if (expiresAt) session.expiresAt = expiresAt;
|
|
843
|
+
if (status) session.status = status;
|
|
844
|
+
return session;
|
|
845
|
+
}
|
|
846
|
+
function describeOptions(payload) {
|
|
847
|
+
return (payload.options ?? []).map((option) => option.value).join(", ");
|
|
848
|
+
}
|
|
849
|
+
function checkAnswer(payload, value) {
|
|
850
|
+
const fail = (expected) => {
|
|
851
|
+
throw new QoniValidationError(`answer() for a ${payload.answerType} question expects ${expected}`);
|
|
852
|
+
};
|
|
853
|
+
const values = new Set((payload.options ?? []).map((option) => option.value));
|
|
854
|
+
switch (payload.answerType) {
|
|
855
|
+
case "text":
|
|
856
|
+
if (typeof value !== "string") fail("a string");
|
|
857
|
+
return;
|
|
858
|
+
case "number":
|
|
859
|
+
if (typeof value !== "number" || !Number.isFinite(value)) fail("a finite number");
|
|
860
|
+
return;
|
|
861
|
+
case "boolean":
|
|
862
|
+
if (typeof value !== "boolean") fail("a boolean");
|
|
863
|
+
return;
|
|
864
|
+
case "single_choice":
|
|
865
|
+
if (typeof value !== "string" || values.size > 0 && !values.has(value)) {
|
|
866
|
+
fail(`one option value: ${describeOptions(payload)}`);
|
|
867
|
+
}
|
|
868
|
+
return;
|
|
869
|
+
case "multiple_choice":
|
|
870
|
+
if (!Array.isArray(value)) fail(`an array of option values: ${describeOptions(payload)}`);
|
|
871
|
+
if (value.some((item) => typeof item !== "string" || values.size > 0 && !values.has(item))) {
|
|
872
|
+
fail(`option values only: ${describeOptions(payload)}`);
|
|
873
|
+
}
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
723
877
|
var InteractionHandle = class {
|
|
724
|
-
|
|
878
|
+
/**
|
|
879
|
+
* @param loginSessions Login sessions opened on interactions, by interaction
|
|
880
|
+
* id. A RunHandle / MonitorHandle shares one store across the handles it
|
|
881
|
+
* hands out, so `confirmSignedIn()` finds the login `openLogin()` opened
|
|
882
|
+
* even on the handle of a later event.
|
|
883
|
+
*/
|
|
884
|
+
constructor(interaction, post, loginSessions = /* @__PURE__ */ new Map()) {
|
|
725
885
|
this.post = post;
|
|
886
|
+
this.loginSessions = loginSessions;
|
|
726
887
|
this.interaction = interaction;
|
|
727
888
|
}
|
|
728
889
|
post;
|
|
890
|
+
loginSessions;
|
|
729
891
|
interaction;
|
|
730
892
|
get id() {
|
|
731
893
|
return this.interaction.id;
|
|
@@ -743,9 +905,18 @@ var InteractionHandle = class {
|
|
|
743
905
|
can(kind) {
|
|
744
906
|
return this.interaction.actions.some((a) => a.kind === kind);
|
|
745
907
|
}
|
|
746
|
-
/**
|
|
747
|
-
|
|
748
|
-
|
|
908
|
+
/**
|
|
909
|
+
* Answer an `ask_user` question. The value follows `payload.answerType`:
|
|
910
|
+
* `text` → string, `number` → number, `boolean` → boolean, `single_choice` →
|
|
911
|
+
* one option `value`, `multiple_choice` → an array of option values. A value
|
|
912
|
+
* that does not fit throws `QoniValidationError` before anything is sent.
|
|
913
|
+
*/
|
|
914
|
+
async answer(value) {
|
|
915
|
+
const action = this.#declaredAction(ActionKinds.answer);
|
|
916
|
+
if (this.interaction.type === "ask_user") {
|
|
917
|
+
checkAnswer(this.interaction.payload, value);
|
|
918
|
+
}
|
|
919
|
+
await this.post(action, { response: value });
|
|
749
920
|
}
|
|
750
921
|
/** Skip / dismiss the interaction (proceed without acting on it). */
|
|
751
922
|
async skip() {
|
|
@@ -763,8 +934,8 @@ var InteractionHandle = class {
|
|
|
763
934
|
* Start an interactive login session for a `site_login` interaction. The
|
|
764
935
|
* backend needs the site to sign in to: pass `siteId`, or omit it when the
|
|
765
936
|
* interaction lists exactly one site. Show `liveUrl` to the user; when they
|
|
766
|
-
*
|
|
767
|
-
*
|
|
937
|
+
* say they are signed in, call {@link confirmSignedIn}, which saves the login
|
|
938
|
+
* and lets the agent continue.
|
|
768
939
|
* The delegation must include `QoniScopes.SITE_LOGIN_REQUEST` and
|
|
769
940
|
* `QoniScopes.SITE_LOGIN_CONFIRM`; the AccessKey policy must allow both.
|
|
770
941
|
*/
|
|
@@ -777,57 +948,82 @@ var InteractionHandle = class {
|
|
|
777
948
|
}
|
|
778
949
|
body.profile_id = options.profileId;
|
|
779
950
|
}
|
|
780
|
-
const
|
|
951
|
+
const slot = this.#loginSlot();
|
|
952
|
+
const call = ++slot.calls;
|
|
953
|
+
slot.inflight += 1;
|
|
954
|
+
let response;
|
|
955
|
+
try {
|
|
956
|
+
response = await this.invoke(ActionKinds.openLogin, body);
|
|
957
|
+
} finally {
|
|
958
|
+
slot.inflight -= 1;
|
|
959
|
+
}
|
|
781
960
|
const raw = isJsonObject(response) ? response : {};
|
|
782
961
|
const fields = camelizeRecord(raw);
|
|
783
962
|
const profileId = asString2(fields.profileId) ?? "";
|
|
784
963
|
const siteId = asString2(fields.siteId) ?? String(body.site_id);
|
|
964
|
+
const sessionId = asString2(fields.sessionId) ?? "";
|
|
965
|
+
const current = slot.session;
|
|
966
|
+
const reused = current !== void 0 && sessionId !== "" && current.sessionId === sessionId && current.profileId === profileId && current.siteId === siteId;
|
|
967
|
+
const session = reused ? current : { profileId, siteId, sessionId, call };
|
|
968
|
+
if (reused) {
|
|
969
|
+
current.call = Math.max(current.call, call);
|
|
970
|
+
} else if (profileId && (current?.call ?? 0) < call) {
|
|
971
|
+
if (current) current.replaced = true;
|
|
972
|
+
slot.session = session;
|
|
973
|
+
} else {
|
|
974
|
+
session.replaced = true;
|
|
975
|
+
}
|
|
785
976
|
return {
|
|
786
977
|
profileId,
|
|
787
978
|
siteId,
|
|
788
|
-
sessionId
|
|
979
|
+
sessionId,
|
|
789
980
|
liveUrl: asString2(fields.liveUrl) ?? "",
|
|
790
981
|
raw,
|
|
791
982
|
confirm: async (confirmOptions = {}) => {
|
|
792
983
|
if (!profileId) {
|
|
793
984
|
throw new QoniValidationError("openLogin response did not include a profileId to confirm");
|
|
794
985
|
}
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
method: "POST",
|
|
802
|
-
endpoint: `/profiles/${encodeURIComponent(profileId)}/confirm`
|
|
803
|
-
},
|
|
804
|
-
confirmBody,
|
|
805
|
-
{ requiredScopes: [QoniScopes.SITE_LOGIN_CONFIRM] }
|
|
806
|
-
);
|
|
807
|
-
const raw2 = isJsonObject(response2) ? response2 : {};
|
|
808
|
-
const fields2 = camelizeRecord(raw2);
|
|
809
|
-
const state = asString2(fields2.state) ?? "";
|
|
810
|
-
return {
|
|
811
|
-
profileId: asString2(fields2.profileId) ?? profileId,
|
|
812
|
-
active: state === "active",
|
|
813
|
-
state,
|
|
814
|
-
probeResult: asString2(fields2.probeResult) ?? "",
|
|
815
|
-
raw: raw2
|
|
816
|
-
};
|
|
986
|
+
if (session.replaced) {
|
|
987
|
+
throw new QoniValidationError(
|
|
988
|
+
"this login session was replaced by a newer openLogin(); confirm the newer one (or call confirmSignedIn(), which saves the current session)"
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
return this.#saveOnce(session, confirmOptions);
|
|
817
992
|
}
|
|
818
993
|
};
|
|
819
994
|
}
|
|
820
|
-
/**
|
|
995
|
+
/**
|
|
996
|
+
* The user finished signing in through the live browser `openLogin()`
|
|
997
|
+
* opened. Saves that login (so later tasks reuse it), then tells the agent
|
|
998
|
+
* to recheck the site and continue. If the user is not signed in after all,
|
|
999
|
+
* the agent raises a new `site_login` interaction.
|
|
1000
|
+
*
|
|
1001
|
+
* Saving needs `QoniScopes.SITE_LOGIN_CONFIRM` on the delegation; without
|
|
1002
|
+
* `openLogin()` on this interaction, only the recheck is sent.
|
|
1003
|
+
*/
|
|
821
1004
|
async confirmSignedIn() {
|
|
822
|
-
|
|
1005
|
+
const action = this.#declaredAction(ActionKinds.confirmSignedIn);
|
|
1006
|
+
const session = this.loginSessions.get(this.id)?.session;
|
|
1007
|
+
if (!session) {
|
|
1008
|
+
await this.post(action, void 0);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
session.done ??= this.#saveThenRecheck(session, action).catch((err) => {
|
|
1012
|
+
session.done = void 0;
|
|
1013
|
+
throw err;
|
|
1014
|
+
});
|
|
1015
|
+
await session.done;
|
|
823
1016
|
}
|
|
824
|
-
/**
|
|
1017
|
+
/**
|
|
1018
|
+
* Take over the live browser for a take_control interaction. Resolves to the
|
|
1019
|
+
* signed control URL to show the user (`liveUrl`) and when it expires.
|
|
1020
|
+
*/
|
|
825
1021
|
async connectControl() {
|
|
826
|
-
await this.invoke(ActionKinds.connectControl);
|
|
1022
|
+
return controlSession(await this.invoke(ActionKinds.connectControl));
|
|
827
1023
|
}
|
|
828
|
-
/**
|
|
1024
|
+
/** Get a fresh control URL when the current one expired. */
|
|
829
1025
|
async refreshControl() {
|
|
830
|
-
await this.invoke(ActionKinds.refreshControl);
|
|
1026
|
+
return controlSession(await this.invoke(ActionKinds.refreshControl));
|
|
831
1027
|
}
|
|
832
1028
|
/** Hand control back to the agent. */
|
|
833
1029
|
async releaseControl() {
|
|
@@ -841,6 +1037,81 @@ var InteractionHandle = class {
|
|
|
841
1037
|
async switchProfile() {
|
|
842
1038
|
await this.invoke(ActionKinds.switchProfile);
|
|
843
1039
|
}
|
|
1040
|
+
/** `POST /profiles/{profileId}/confirm` — persist the login of `session`. */
|
|
1041
|
+
async #confirmLogin(session, options = {}) {
|
|
1042
|
+
const confirmBody = { site_id: session.siteId };
|
|
1043
|
+
if (options.displayLabel !== void 0) confirmBody.display_label = options.displayLabel;
|
|
1044
|
+
const response = await this.post(
|
|
1045
|
+
{
|
|
1046
|
+
kind: ActionKinds.openLogin,
|
|
1047
|
+
label: "Confirm login",
|
|
1048
|
+
method: "POST",
|
|
1049
|
+
endpoint: `/profiles/${encodeURIComponent(session.profileId)}/confirm`
|
|
1050
|
+
},
|
|
1051
|
+
confirmBody,
|
|
1052
|
+
{ requiredScopes: [QoniScopes.SITE_LOGIN_CONFIRM] }
|
|
1053
|
+
);
|
|
1054
|
+
const raw = isJsonObject(response) ? response : {};
|
|
1055
|
+
const fields = camelizeRecord(raw);
|
|
1056
|
+
const state = asString2(fields.state) ?? "";
|
|
1057
|
+
return {
|
|
1058
|
+
profileId: asString2(fields.profileId) ?? session.profileId,
|
|
1059
|
+
active: state === "active",
|
|
1060
|
+
state,
|
|
1061
|
+
probeResult: asString2(fields.probeResult) ?? "",
|
|
1062
|
+
raw
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
/** The save for `session`, started once; a failed attempt is forgotten so a retry saves again. */
|
|
1066
|
+
#saveOnce(session, options) {
|
|
1067
|
+
session.save ??= this.#confirmLogin(session, options).then(
|
|
1068
|
+
(result) => {
|
|
1069
|
+
if (SAVE_RETRY_PROBES.has(result.probeResult)) session.save = void 0;
|
|
1070
|
+
return result;
|
|
1071
|
+
},
|
|
1072
|
+
(err) => {
|
|
1073
|
+
if (!isNoPendingLogin(err)) session.save = void 0;
|
|
1074
|
+
throw err;
|
|
1075
|
+
}
|
|
1076
|
+
);
|
|
1077
|
+
return session.save;
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Save the login, then let the agent recheck. A user who is not signed in
|
|
1081
|
+
* yet still gets a recheck (the agent asks again), and so does 409
|
|
1082
|
+
* `no_pending_login`. A save the backend could not complete (`release_failed`)
|
|
1083
|
+
* throws a retryable error instead of telling the agent to go on.
|
|
1084
|
+
*/
|
|
1085
|
+
async #saveThenRecheck(session, action) {
|
|
1086
|
+
let result;
|
|
1087
|
+
try {
|
|
1088
|
+
result = await this.#saveOnce(session);
|
|
1089
|
+
} catch (err) {
|
|
1090
|
+
if (!isNoPendingLogin(err)) throw err;
|
|
1091
|
+
}
|
|
1092
|
+
if (result && SAVE_RETRY_PROBES.has(result.probeResult)) {
|
|
1093
|
+
throw new QoniError(
|
|
1094
|
+
`the login could not be saved yet (probeResult "${result.probeResult}"); the login browser is kept \u2014 call confirmSignedIn() again to retry`,
|
|
1095
|
+
{ code: "site_login.save_failed", retryable: true }
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
if (session.replaced) {
|
|
1099
|
+
throw new QoniError(
|
|
1100
|
+
"the login was reopened while it was being saved; call confirmSignedIn() again to save the newer login",
|
|
1101
|
+
{ code: "site_login.session_replaced", retryable: true }
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
await this.post(action, void 0);
|
|
1105
|
+
session.completed = true;
|
|
1106
|
+
}
|
|
1107
|
+
#loginSlot() {
|
|
1108
|
+
let slot = this.loginSessions.get(this.id);
|
|
1109
|
+
if (!slot) {
|
|
1110
|
+
slot = { calls: 0, inflight: 0 };
|
|
1111
|
+
rememberBounded(this.loginSessions, this.id, slot, INTERACTION_MEMORY_LIMIT, loginSlotIdle);
|
|
1112
|
+
}
|
|
1113
|
+
return slot;
|
|
1114
|
+
}
|
|
844
1115
|
/** Resolve the site for `openLogin`: explicit, or the only listed site. */
|
|
845
1116
|
#loginSiteId(requested) {
|
|
846
1117
|
const payload = this.interaction.payload;
|
|
@@ -896,6 +1167,10 @@ var RunHandle = class {
|
|
|
896
1167
|
/** Pass back to `run({ session })` to reuse the same backend session. */
|
|
897
1168
|
sessionRef;
|
|
898
1169
|
capture;
|
|
1170
|
+
/** Login sessions opened on this run's interactions — see {@link interactionHandle}. */
|
|
1171
|
+
#loginSessions = /* @__PURE__ */ new Map();
|
|
1172
|
+
/** Latest card per interaction id, so lifecycle updates fold onto it. */
|
|
1173
|
+
#interactions = /* @__PURE__ */ new Map();
|
|
899
1174
|
/** Refresh and return the run's current state. */
|
|
900
1175
|
async status() {
|
|
901
1176
|
const detail = await this.ops.getDetail();
|
|
@@ -909,8 +1184,14 @@ var RunHandle = class {
|
|
|
909
1184
|
async *events(opts = {}) {
|
|
910
1185
|
for await (const wire of this.ops.streamEvents(opts)) {
|
|
911
1186
|
await opts.onWireEvent?.(wire);
|
|
912
|
-
|
|
1187
|
+
let event = normalizeRunEvent(wire, { topRunId: this.id });
|
|
913
1188
|
if (!event) continue;
|
|
1189
|
+
if (event.type === "interaction") {
|
|
1190
|
+
const card = foldInteraction(this.#interactions.get(event.data.id), event.data);
|
|
1191
|
+
rememberBounded(this.#interactions, card.id, card, INTERACTION_MEMORY_LIMIT, interactionSettled);
|
|
1192
|
+
markLoginEnded(this.#loginSessions, card);
|
|
1193
|
+
event = { ...event, data: card };
|
|
1194
|
+
}
|
|
914
1195
|
if (event.type === "screenshot" && !this.capture.screenshots) continue;
|
|
915
1196
|
if (isVideoFrame(wire) && !this.capture.videoFrames) continue;
|
|
916
1197
|
yield event;
|
|
@@ -983,12 +1264,16 @@ var RunHandle = class {
|
|
|
983
1264
|
* Wrap an {@link Interaction} (from `event.data` of an `interaction` event)
|
|
984
1265
|
* in a typed {@link InteractionHandle} bound to this run. The handle's action
|
|
985
1266
|
* methods (`answer` / `confirmSignedIn` / `releaseControl` / …) post to the
|
|
986
|
-
* endpoint the backend declared for that action.
|
|
1267
|
+
* endpoint the backend declared for that action. Handles this RunHandle
|
|
1268
|
+
* hands out share the login sessions `openLogin()` opens, so
|
|
1269
|
+
* `confirmSignedIn()` on the handle of a later event still saves that login.
|
|
1270
|
+
* A RunHandle from another `attach()` (or process) does not see them.
|
|
987
1271
|
*/
|
|
988
1272
|
interactionHandle(interaction) {
|
|
989
1273
|
return new InteractionHandle(
|
|
990
1274
|
interaction,
|
|
991
|
-
(action, body, options) => this.ops.postAction(action, body, options)
|
|
1275
|
+
(action, body, options) => this.ops.postAction(action, body, options),
|
|
1276
|
+
this.#loginSessions
|
|
992
1277
|
);
|
|
993
1278
|
}
|
|
994
1279
|
/**
|
|
@@ -1612,7 +1897,14 @@ function createQoniNamespace(_transport, delegateToken, completeDelegateToken) {
|
|
|
1612
1897
|
|
|
1613
1898
|
// src/genauth.ts
|
|
1614
1899
|
function createGenAuthNamespace(transport, options = {}) {
|
|
1900
|
+
async function delegateAgent(input) {
|
|
1901
|
+
if (!options.delegateTokenFor) {
|
|
1902
|
+
throw new QoniValidationError("GenAuth delegateAgent requires a Qoni delegation provider");
|
|
1903
|
+
}
|
|
1904
|
+
return (await options.delegateTokenFor(input)).data;
|
|
1905
|
+
}
|
|
1615
1906
|
return {
|
|
1907
|
+
delegateAgent,
|
|
1616
1908
|
userInfo: (input) => transport.genauthJson("GET", "/oidc/me", input.accessToken),
|
|
1617
1909
|
jwks: () => transport.genauthJson("GET", "/oidc/.well-known/jwks.json"),
|
|
1618
1910
|
discovery: () => transport.genauthJson("GET", "/oidc/.well-known/openid-configuration"),
|
|
@@ -1806,6 +2098,10 @@ var MonitorHandle = class {
|
|
|
1806
2098
|
}
|
|
1807
2099
|
ops;
|
|
1808
2100
|
id;
|
|
2101
|
+
/** Login sessions opened on this monitor's interactions — see {@link interactionHandle}. */
|
|
2102
|
+
#loginSessions = /* @__PURE__ */ new Map();
|
|
2103
|
+
/** Latest card per interaction id, so settling shells fold onto it. */
|
|
2104
|
+
#interactions = /* @__PURE__ */ new Map();
|
|
1809
2105
|
/** Refresh and return the monitor (normalized camelCase fields). */
|
|
1810
2106
|
async get() {
|
|
1811
2107
|
return normalizeMonitorRecord(await this.ops.get());
|
|
@@ -1850,7 +2146,13 @@ var MonitorHandle = class {
|
|
|
1850
2146
|
*/
|
|
1851
2147
|
async *events(opts = {}) {
|
|
1852
2148
|
for await (const wire of this.ops.streamEvents(opts)) {
|
|
1853
|
-
|
|
2149
|
+
let event = normalizeRunEvent(wire);
|
|
2150
|
+
if (event?.type === "interaction") {
|
|
2151
|
+
const card = foldInteraction(this.#interactions.get(event.data.id), event.data);
|
|
2152
|
+
rememberBounded(this.#interactions, card.id, card, INTERACTION_MEMORY_LIMIT, interactionSettled);
|
|
2153
|
+
markLoginEnded(this.#loginSessions, card);
|
|
2154
|
+
event = { ...event, data: card };
|
|
2155
|
+
}
|
|
1854
2156
|
if (event) yield event;
|
|
1855
2157
|
}
|
|
1856
2158
|
}
|
|
@@ -1858,12 +2160,14 @@ var MonitorHandle = class {
|
|
|
1858
2160
|
* Wrap an {@link Interaction} (from `event.data` of an `interaction` event on
|
|
1859
2161
|
* the monitor stream — e.g. a `site_login` to unlock a `needs_reauth` park)
|
|
1860
2162
|
* in a typed {@link InteractionHandle}. The handle's action methods post to
|
|
1861
|
-
* the endpoint the backend declared for that action.
|
|
2163
|
+
* the endpoint the backend declared for that action. Handles of one
|
|
2164
|
+
* MonitorHandle share the login sessions `openLogin()` opens.
|
|
1862
2165
|
*/
|
|
1863
2166
|
interactionHandle(interaction) {
|
|
1864
2167
|
return new InteractionHandle(
|
|
1865
2168
|
interaction,
|
|
1866
|
-
(action, body, options) => this.ops.postAction(action, body, options)
|
|
2169
|
+
(action, body, options) => this.ops.postAction(action, body, options),
|
|
2170
|
+
this.#loginSessions
|
|
1867
2171
|
);
|
|
1868
2172
|
}
|
|
1869
2173
|
/**
|
|
@@ -2248,7 +2552,8 @@ var Qoni = class {
|
|
|
2248
2552
|
webAgentSSE: (...args) => this.webAgentSSE(...args)
|
|
2249
2553
|
};
|
|
2250
2554
|
this.genauth = createGenAuthNamespace(transport, {
|
|
2251
|
-
adminTokenFor: () => this.genauthAdminTokenFor()
|
|
2555
|
+
adminTokenFor: () => this.genauthAdminTokenFor(),
|
|
2556
|
+
delegateTokenFor: (input) => this.delegateToken(input)
|
|
2252
2557
|
});
|
|
2253
2558
|
this.qoni = createQoniNamespace(
|
|
2254
2559
|
transport,
|
|
@@ -2961,19 +3266,43 @@ function toRecord(value) {
|
|
|
2961
3266
|
}
|
|
2962
3267
|
function normalizeDelegateTokenInput(input) {
|
|
2963
3268
|
const body = toRecord(input);
|
|
3269
|
+
if (input.deniedScopes !== void 0) {
|
|
3270
|
+
throw new QoniValidationError(
|
|
3271
|
+
"delegateToken: deniedScopes is not supported by the delegation service. Do not rely on this field to prevent mailbox or browser actions."
|
|
3272
|
+
);
|
|
3273
|
+
}
|
|
3274
|
+
if (input.agent !== void 0 && input.agentKey !== void 0 && input.agent !== input.agentKey) {
|
|
3275
|
+
throw new QoniValidationError("delegateToken: agent and agentKey must identify the same Agent");
|
|
3276
|
+
}
|
|
2964
3277
|
if (!body.userId && isJsonObject(input.user) && typeof input.user.id === "string") {
|
|
2965
3278
|
body.userId = input.user.id;
|
|
2966
3279
|
}
|
|
2967
3280
|
if (input.mode !== "interactive" && !body.userId) {
|
|
2968
3281
|
throw new QoniValidationError(
|
|
2969
|
-
'delegateToken: silent mode requires a user \u2014 pass `user: { id: <genauthUserId> }` (or the
|
|
3282
|
+
'delegateToken: silent mode requires a user \u2014 pass `user: { id: <genauthUserId> }` (or the top-level `userId`). Interactive mode (`mode: "interactive"`) does not need one.'
|
|
2970
3283
|
);
|
|
2971
3284
|
}
|
|
2972
|
-
body.agent = input.agent ?? "sdk";
|
|
3285
|
+
body.agent = input.agent ?? input.agentKey ?? "sdk";
|
|
3286
|
+
delete body.agentKey;
|
|
3287
|
+
if (input.expiresIn !== void 0) body.expiresIn = delegationDurationSeconds(input.expiresIn);
|
|
2973
3288
|
body.scopes = resolveDelegationScopes(input.scopes, input.products);
|
|
2974
3289
|
delete body.products;
|
|
2975
3290
|
return body;
|
|
2976
3291
|
}
|
|
3292
|
+
function delegationDurationSeconds(value) {
|
|
3293
|
+
let seconds = value;
|
|
3294
|
+
if (typeof value === "string") {
|
|
3295
|
+
const duration = /^(\d+)(s|m|h|d)?$/.exec(value);
|
|
3296
|
+
const units = { s: 1, m: 60, h: 3600, d: 86400 };
|
|
3297
|
+
seconds = duration ? Number(duration[1]) * units[duration[2] ?? "s"] : NaN;
|
|
3298
|
+
}
|
|
3299
|
+
if (typeof seconds !== "number" || !Number.isInteger(seconds) || seconds < 60 || seconds > 86400) {
|
|
3300
|
+
throw new QoniValidationError(
|
|
3301
|
+
'delegateToken: expiresIn must be 60\u201386400 integer seconds or a whole-number duration such as "15m"'
|
|
3302
|
+
);
|
|
3303
|
+
}
|
|
3304
|
+
return seconds;
|
|
3305
|
+
}
|
|
2977
3306
|
function resolveDelegationScopes(scopes, products) {
|
|
2978
3307
|
const resolved = /* @__PURE__ */ new Set();
|
|
2979
3308
|
for (const product of products ?? []) {
|
|
@@ -2997,11 +3326,16 @@ function resolveDelegationScopes(scopes, products) {
|
|
|
2997
3326
|
return [...resolved];
|
|
2998
3327
|
}
|
|
2999
3328
|
function validateScopeFormat(scope) {
|
|
3329
|
+
if (scope.startsWith("mailbox.") || scope === "web.search" || scope === "web.extract") {
|
|
3330
|
+
throw new QoniValidationError(
|
|
3331
|
+
`delegateToken: legacy action scope "${scope}" has no enforced mapping in this delegation service. It will not be expanded into broader product permissions.`
|
|
3332
|
+
);
|
|
3333
|
+
}
|
|
3000
3334
|
if (KNOWN_SCOPES.includes(scope)) return;
|
|
3001
|
-
const prefixed = KNOWN_SCOPES.
|
|
3002
|
-
if (prefixed) {
|
|
3335
|
+
const prefixed = KNOWN_SCOPES.filter((known) => known.endsWith(`.${scope}`));
|
|
3336
|
+
if (prefixed.length > 0) {
|
|
3003
3337
|
throw new QoniValidationError(
|
|
3004
|
-
`delegateToken: scope "${scope}" is missing its service prefix \u2014 write "${
|
|
3338
|
+
`delegateToken: scope "${scope}" is missing its service prefix \u2014 write ` + prefixed.map((known) => `"${known}"`).join(" or ")
|
|
3005
3339
|
);
|
|
3006
3340
|
}
|
|
3007
3341
|
if (!/^[a-z0-9_]+(\.[a-z0-9_]+)+:[a-z0-9_]+$/.test(scope)) {
|