@agiledigital/pingone-aic-script-tester 0.1.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/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +274 -0
- package/dist/bin/fetch-jar.d.ts +2 -0
- package/dist/bin/fetch-jar.js +13 -0
- package/dist/bin/pull-profile.d.ts +2 -0
- package/dist/bin/pull-profile.js +40 -0
- package/dist/bin/show-log.d.ts +2 -0
- package/dist/bin/show-log.js +31 -0
- package/dist/classes/.complete +12 -0
- package/dist/classes/AmClassShutter.class +0 -0
- package/dist/classes/AmContextFactory$ObservedContext.class +0 -0
- package/dist/classes/AmContextFactory.class +0 -0
- package/dist/classes/AmScopes.class +0 -0
- package/dist/classes/HostOps.class +0 -0
- package/dist/classes/JsValues.class +0 -0
- package/dist/classes/Json$Parser.class +0 -0
- package/dist/classes/Json.class +0 -0
- package/dist/classes/LanguageVersions.class +0 -0
- package/dist/classes/Runner.class +0 -0
- package/dist/classes/ScriptContextScope.class +0 -0
- package/dist/src/aic/callbacks.d.ts +35 -0
- package/dist/src/aic/callbacks.js +130 -0
- package/dist/src/aic/conform.d.ts +90 -0
- package/dist/src/aic/conform.js +193 -0
- package/dist/src/aic/constants.d.ts +13 -0
- package/dist/src/aic/constants.js +13 -0
- package/dist/src/aic/diff.d.ts +25 -0
- package/dist/src/aic/diff.js +201 -0
- package/dist/src/aic/emit-journey.d.ts +64 -0
- package/dist/src/aic/emit-journey.js +254 -0
- package/dist/src/aic/emit-result.d.ts +10 -0
- package/dist/src/aic/emit-result.js +39 -0
- package/dist/src/aic/emit-session.d.ts +26 -0
- package/dist/src/aic/emit-session.js +132 -0
- package/dist/src/aic/emit-subject.d.ts +40 -0
- package/dist/src/aic/emit-subject.js +210 -0
- package/dist/src/aic/file-lease.d.ts +35 -0
- package/dist/src/aic/file-lease.js +543 -0
- package/dist/src/aic/http.d.ts +21 -0
- package/dist/src/aic/http.js +127 -0
- package/dist/src/aic/idm.d.ts +10 -0
- package/dist/src/aic/idm.js +112 -0
- package/dist/src/aic/index.d.ts +35 -0
- package/dist/src/aic/index.js +21 -0
- package/dist/src/aic/js.d.ts +8 -0
- package/dist/src/aic/js.js +11 -0
- package/dist/src/aic/lease-identity.d.ts +38 -0
- package/dist/src/aic/lease-identity.js +72 -0
- package/dist/src/aic/lease-lock.d.ts +45 -0
- package/dist/src/aic/lease-lock.js +173 -0
- package/dist/src/aic/managed.d.ts +28 -0
- package/dist/src/aic/managed.js +199 -0
- package/dist/src/aic/provider.d.ts +156 -0
- package/dist/src/aic/provider.js +416 -0
- package/dist/src/aic/record.d.ts +28 -0
- package/dist/src/aic/record.js +183 -0
- package/dist/src/aic/resource-snapshot.d.ts +12 -0
- package/dist/src/aic/resource-snapshot.js +116 -0
- package/dist/src/aic/run.d.ts +102 -0
- package/dist/src/aic/run.js +450 -0
- package/dist/src/aic/tenant.d.ts +107 -0
- package/dist/src/aic/tenant.js +307 -0
- package/dist/src/aic/trace.d.ts +25 -0
- package/dist/src/aic/trace.js +56 -0
- package/dist/src/aic/txid.d.ts +11 -0
- package/dist/src/aic/txid.js +25 -0
- package/dist/src/aic/unsupported.d.ts +15 -0
- package/dist/src/aic/unsupported.js +60 -0
- package/dist/src/bindings/allowlist.d.ts +42 -0
- package/dist/src/bindings/allowlist.js +62 -0
- package/dist/src/bindings/harvest.d.ts +7 -0
- package/dist/src/bindings/harvest.js +137 -0
- package/dist/src/bindings/index.d.ts +4 -0
- package/dist/src/bindings/index.js +3 -0
- package/dist/src/bindings/preamble.d.ts +23 -0
- package/dist/src/bindings/preamble.js +36 -0
- package/dist/src/bindings/run.d.ts +33 -0
- package/dist/src/bindings/run.js +55 -0
- package/dist/src/case/equal.d.ts +2 -0
- package/dist/src/case/equal.js +40 -0
- package/dist/src/case/index.d.ts +5 -0
- package/dist/src/case/index.js +4 -0
- package/dist/src/case/portable.d.ts +10 -0
- package/dist/src/case/portable.js +28 -0
- package/dist/src/case/session.d.ts +34 -0
- package/dist/src/case/session.js +66 -0
- package/dist/src/case/state.d.ts +4 -0
- package/dist/src/case/state.js +47 -0
- package/dist/src/case/types.d.ts +266 -0
- package/dist/src/case/types.js +106 -0
- package/dist/src/case/util.d.ts +7 -0
- package/dist/src/case/util.js +115 -0
- package/dist/src/case/validate.d.ts +8 -0
- package/dist/src/case/validate.js +463 -0
- package/dist/src/case/verdict.d.ts +6 -0
- package/dist/src/case/verdict.js +810 -0
- package/dist/src/diagnostics.d.ts +7 -0
- package/dist/src/diagnostics.js +5 -0
- package/dist/src/emit-dts.d.ts +2 -0
- package/dist/src/emit-dts.js +82 -0
- package/dist/src/emit-js.d.ts +3 -0
- package/dist/src/emit-js.js +149 -0
- package/dist/src/harness/failures.d.ts +26 -0
- package/dist/src/harness/failures.js +113 -0
- package/dist/src/harness/idm.d.ts +12 -0
- package/dist/src/harness/idm.js +44 -0
- package/dist/src/harness/index.d.ts +11 -0
- package/dist/src/harness/index.js +6 -0
- package/dist/src/harness/lease.d.ts +136 -0
- package/dist/src/harness/lease.js +286 -0
- package/dist/src/harness/residue.d.ts +23 -0
- package/dist/src/harness/residue.js +73 -0
- package/dist/src/harness/show-log.d.ts +43 -0
- package/dist/src/harness/show-log.js +253 -0
- package/dist/src/harness/spec.d.ts +67 -0
- package/dist/src/harness/spec.js +189 -0
- package/dist/src/harness/step.d.ts +80 -0
- package/dist/src/harness/step.js +97 -0
- package/dist/src/harness/types.d.ts +100 -0
- package/dist/src/harness/types.js +1 -0
- package/dist/src/harness/vitest.d.ts +52 -0
- package/dist/src/harness/vitest.js +155 -0
- package/dist/src/idents.d.ts +4 -0
- package/dist/src/idents.js +42 -0
- package/dist/src/jvm.d.ts +124 -0
- package/dist/src/jvm.js +432 -0
- package/dist/src/load.d.ts +2 -0
- package/dist/src/load.js +104 -0
- package/dist/src/paths.d.ts +36 -0
- package/dist/src/paths.js +50 -0
- package/dist/src/profile/index.d.ts +5 -0
- package/dist/src/profile/index.js +3 -0
- package/dist/src/profile/normalise.d.ts +16 -0
- package/dist/src/profile/normalise.js +106 -0
- package/dist/src/profile/pull.d.ts +22 -0
- package/dist/src/profile/pull.js +36 -0
- package/dist/src/profile/seed.d.ts +11 -0
- package/dist/src/profile/seed.js +28 -0
- package/dist/src/profile/store.d.ts +13 -0
- package/dist/src/profile/store.js +60 -0
- package/dist/src/profile/types.d.ts +45 -0
- package/dist/src/profile/types.js +1 -0
- package/dist/src/profile/validate.d.ts +38 -0
- package/dist/src/profile/validate.js +129 -0
- package/dist/src/project.d.ts +37 -0
- package/dist/src/project.js +93 -0
- package/dist/src/protocol.d.ts +62 -0
- package/dist/src/protocol.js +68 -0
- package/dist/src/provider-module.d.ts +11 -0
- package/dist/src/provider-module.js +46 -0
- package/dist/src/runner.d.ts +39 -0
- package/dist/src/runner.js +337 -0
- package/dist/src/schema.d.ts +48 -0
- package/dist/src/schema.js +93 -0
- package/generated/scripted-decision-mocks.cjs +722 -0
- package/generated/scripted-decision-mocks.d.ts +626 -0
- package/generated/scripted-decision-next.json +3640 -0
- package/java/AmClassShutter.java +98 -0
- package/java/AmContextFactory.java +69 -0
- package/java/AmScopes.java +49 -0
- package/java/HostOps.java +180 -0
- package/java/JsValues.java +39 -0
- package/java/Json.java +441 -0
- package/java/LanguageVersions.java +55 -0
- package/java/Runner.java +289 -0
- package/java/ScriptContextScope.java +147 -0
- package/package.json +88 -0
- package/src/bindings/rhino/runtime.cjs +3021 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { type HttpRequest, type HttpResponse } from "./http.ts";
|
|
2
|
+
import { type TenantProvider } from "./provider.ts";
|
|
3
|
+
export interface TenantSession {
|
|
4
|
+
tenantName: string;
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
token: string;
|
|
7
|
+
/** Where `token` came from, and where a replacement comes from after a 401. */
|
|
8
|
+
provider: TenantProvider;
|
|
9
|
+
}
|
|
10
|
+
export interface CliResult {
|
|
11
|
+
status: number;
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
}
|
|
15
|
+
export interface AmResponse {
|
|
16
|
+
status: number;
|
|
17
|
+
bodyText: string;
|
|
18
|
+
body: unknown;
|
|
19
|
+
transactionId?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface AmRequest {
|
|
22
|
+
method: string;
|
|
23
|
+
path: string;
|
|
24
|
+
headers?: Array<[string, string]>;
|
|
25
|
+
body?: string;
|
|
26
|
+
/** When true, do not send the service-account bearer (authenticate). */
|
|
27
|
+
anonymous?: boolean;
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
}
|
|
30
|
+
export interface AicIo {
|
|
31
|
+
aic(args: string[]): Promise<CliResult>;
|
|
32
|
+
http(req: HttpRequest): Promise<HttpResponse>;
|
|
33
|
+
}
|
|
34
|
+
export declare const AM_CONFIG_API_VERSION = "protocol=2.0,resource=1.0";
|
|
35
|
+
export declare function amConfigHeaders(): Array<[string, string]>;
|
|
36
|
+
export declare class AicLaneError extends Error {
|
|
37
|
+
readonly status?: number;
|
|
38
|
+
readonly transactionId?: string;
|
|
39
|
+
constructor(message: string, options?: {
|
|
40
|
+
status?: number;
|
|
41
|
+
transactionId?: string;
|
|
42
|
+
cause?: unknown;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
export declare function defaultAicIo(project: string): AicIo;
|
|
46
|
+
export interface ConnectOptions {
|
|
47
|
+
/**
|
|
48
|
+
* An `aic` context name. With the `aic` provider it selects the context;
|
|
49
|
+
* with any other it must equal the provider's name, so a suite pinned to
|
|
50
|
+
* one tenant cannot run against another.
|
|
51
|
+
*/
|
|
52
|
+
tenant?: string;
|
|
53
|
+
/** The `aic` project directory; the `aic` provider only. */
|
|
54
|
+
project?: string;
|
|
55
|
+
/** Default: {@link configuredTenantProvider}, else the `aic` CLI. */
|
|
56
|
+
provider?: TenantProvider;
|
|
57
|
+
/**
|
|
58
|
+
* `false` skips the registered and environment providers. Callers pass it
|
|
59
|
+
* when they were handed an `io`: an injected `io` is a test seam for both
|
|
60
|
+
* `aic` and HTTP, and a configured service account would mint its token
|
|
61
|
+
* over the real network behind it.
|
|
62
|
+
*/
|
|
63
|
+
useConfigured?: boolean;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The provider a connection uses: the one passed, else the one registered
|
|
67
|
+
* with `setTenantProvider` or configured by `AIC_SCRIPT_TESTER_TENANT_URL`, else
|
|
68
|
+
* this repo's `aic` agent.
|
|
69
|
+
*/
|
|
70
|
+
export declare function resolveTenantProvider(io: AicIo, options?: ConnectOptions): TenantProvider;
|
|
71
|
+
export declare function connectTenant(io: AicIo, options?: ConnectOptions): Promise<TenantSession>;
|
|
72
|
+
/**
|
|
73
|
+
* This repo's provider: the tenant and bearer come from the `aic` agent
|
|
74
|
+
* (`aic ctx list --json`, `aic whoami --token`), and logs from
|
|
75
|
+
* `aic logs tx --wait`. The agent must be unlocked; every call passes
|
|
76
|
+
* `--no-prompt` so a locked one fails instead of waiting for a password.
|
|
77
|
+
*/
|
|
78
|
+
export declare function aicCliProvider(io: AicIo, options?: {
|
|
79
|
+
tenant?: string;
|
|
80
|
+
project?: string;
|
|
81
|
+
}): TenantProvider;
|
|
82
|
+
/**
|
|
83
|
+
* Ask the provider for a replacement bearer and adopt it.
|
|
84
|
+
*
|
|
85
|
+
* The history below is the `aic` provider's, and it is why a 401 must reach
|
|
86
|
+
* the provider as `"rejected"`: a provider that caches has to know the token
|
|
87
|
+
* it holds is dead, rather than handing it back.
|
|
88
|
+
*
|
|
89
|
+
* MEASURED 2026-09-14 over 20 minutes: `aic whoami --token` handed back a
|
|
90
|
+
* token that worked *right now* and said nothing about how much life was left
|
|
91
|
+
* in it. The first token this probe was given was rejected 401 five minutes
|
|
92
|
+
* later, while a token fetched moments after it stayed good for the next ten —
|
|
93
|
+
* the agent rotated on its own ~898s schedule, so what you got depended on
|
|
94
|
+
* where in that cycle you asked.
|
|
95
|
+
*
|
|
96
|
+
* `aic` now guarantees `--token` at least 840 of those seconds, minting when
|
|
97
|
+
* the cached token is below the floor (MEASURED 2026-09-15: with 823s left the
|
|
98
|
+
* TTL jumped to 899 across the call, and with 864s left it did not move). That
|
|
99
|
+
* removes the reason this refresh usually fired, and none of the reasons it
|
|
100
|
+
* must stay: a file may run longer than the floor, the tenant may revoke, and
|
|
101
|
+
* an older `aic` on someone else's PATH makes no such promise. So the 401 retry
|
|
102
|
+
* remains the correctness mechanism and the floor is only what makes it rare.
|
|
103
|
+
*/
|
|
104
|
+
export declare function refreshSessionToken(session: TenantSession): Promise<void>;
|
|
105
|
+
export declare function realmJsonPath(realm: string): string;
|
|
106
|
+
export declare function amRequest(io: AicIo, session: TenantSession, req: AmRequest): Promise<AmResponse>;
|
|
107
|
+
export declare function redact(text: string, session: TenantSession): string;
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { isPlainObject } from "../case/util.js";
|
|
5
|
+
import { projectRoot } from "../project.js";
|
|
6
|
+
import { headerValues, sendHttp } from "./http.js";
|
|
7
|
+
import { checkTenantDescription, configuredTenantProvider, TENANT_ENV, } from "./provider.js";
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
export const AM_CONFIG_API_VERSION = "protocol=2.0,resource=1.0";
|
|
10
|
+
export function amConfigHeaders() {
|
|
11
|
+
return [["Accept-API-Version", AM_CONFIG_API_VERSION]];
|
|
12
|
+
}
|
|
13
|
+
export class AicLaneError extends Error {
|
|
14
|
+
status;
|
|
15
|
+
transactionId;
|
|
16
|
+
constructor(message, options = {}) {
|
|
17
|
+
super(message, options.cause !== undefined ? { cause: options.cause } : {});
|
|
18
|
+
this.name = "AicLaneError";
|
|
19
|
+
if (options.status !== undefined) {
|
|
20
|
+
this.status = options.status;
|
|
21
|
+
}
|
|
22
|
+
if (options.transactionId !== undefined) {
|
|
23
|
+
this.transactionId = options.transactionId;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function defaultAicIo(project) {
|
|
28
|
+
const bin = process.env.AIC_BIN ?? join(project, "target", "debug", "aic");
|
|
29
|
+
return {
|
|
30
|
+
async aic(args) {
|
|
31
|
+
try {
|
|
32
|
+
const result = await execFileAsync(bin, args, {
|
|
33
|
+
cwd: project,
|
|
34
|
+
timeout: 30_000,
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
maxBuffer: 2_000_000,
|
|
37
|
+
});
|
|
38
|
+
return { status: 0, stdout: result.stdout, stderr: result.stderr };
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (isExecError(error) && error.code === "ENOENT") {
|
|
42
|
+
throw new AicLaneError(`no tenant provider configured: pass \`provider\`, call setTenantProvider(), ` +
|
|
43
|
+
`or set ${TENANT_ENV.url} (see docs/rhino-local-harness.md); ` +
|
|
44
|
+
`the aic fallback needs the aic CLI, and ${bin} does not exist (set AIC_BIN)`, { cause: error });
|
|
45
|
+
}
|
|
46
|
+
if (isExecError(error)) {
|
|
47
|
+
return {
|
|
48
|
+
status: error.code === null || error.code === undefined ? 1 : execStatus(error.code),
|
|
49
|
+
stdout: error.stdout ?? "",
|
|
50
|
+
stderr: error.stderr ?? error.message,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
http: sendHttp,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function isExecError(error) {
|
|
60
|
+
return error instanceof Error;
|
|
61
|
+
}
|
|
62
|
+
function execStatus(code) {
|
|
63
|
+
return typeof code === "number" ? code : 1;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The provider a connection uses: the one passed, else the one registered
|
|
67
|
+
* with `setTenantProvider` or configured by `AIC_SCRIPT_TESTER_TENANT_URL`, else
|
|
68
|
+
* this repo's `aic` agent.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveTenantProvider(io, options = {}) {
|
|
71
|
+
return (options.provider ??
|
|
72
|
+
(options.useConfigured === false ? undefined : configuredTenantProvider()) ??
|
|
73
|
+
aicCliProvider(io, {
|
|
74
|
+
...(options.tenant === undefined ? {} : { tenant: options.tenant }),
|
|
75
|
+
...(options.project === undefined ? {} : { project: options.project }),
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
export async function connectTenant(io, options = {}) {
|
|
79
|
+
const provider = resolveTenantProvider(io, options);
|
|
80
|
+
const description = checkTenantDescription(await provider.describe());
|
|
81
|
+
if (options.tenant !== undefined && options.tenant !== description.name) {
|
|
82
|
+
throw new AicLaneError(
|
|
83
|
+
// Neither name is quoted: a provider's name defaults to its hostname.
|
|
84
|
+
"the tenant provider serves a different tenant from the one this run asks for (the `tenant` option)");
|
|
85
|
+
}
|
|
86
|
+
const token = await provider.getToken({ reason: "initial" });
|
|
87
|
+
return {
|
|
88
|
+
tenantName: description.name,
|
|
89
|
+
baseUrl: stripSlash(description.baseUrl),
|
|
90
|
+
token,
|
|
91
|
+
provider,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* This repo's provider: the tenant and bearer come from the `aic` agent
|
|
96
|
+
* (`aic ctx list --json`, `aic whoami --token`), and logs from
|
|
97
|
+
* `aic logs tx --wait`. The agent must be unlocked; every call passes
|
|
98
|
+
* `--no-prompt` so a locked one fails instead of waiting for a password.
|
|
99
|
+
*/
|
|
100
|
+
export function aicCliProvider(io, options = {}) {
|
|
101
|
+
const project = options.project ?? projectRoot();
|
|
102
|
+
const noPrompt = ["--no-prompt", "--project", project];
|
|
103
|
+
let chosen;
|
|
104
|
+
function describe() {
|
|
105
|
+
chosen ??= chooseContext(io, noPrompt, options.tenant);
|
|
106
|
+
return chosen;
|
|
107
|
+
}
|
|
108
|
+
const logs = {
|
|
109
|
+
async transaction(id, readOptions = {}) {
|
|
110
|
+
const { name } = await describe();
|
|
111
|
+
const args = [...noPrompt, "logs", "tx", id, "--tenant", name, "--wait"];
|
|
112
|
+
if (readOptions.waitMs !== undefined) {
|
|
113
|
+
args.push("--timeout", String(Math.max(1, Math.ceil(readOptions.waitMs / 1000))));
|
|
114
|
+
}
|
|
115
|
+
const result = await io.aic(args);
|
|
116
|
+
if (result.status !== 0) {
|
|
117
|
+
throw new AicLaneError(formatLogsFailure(result), { status: result.status });
|
|
118
|
+
}
|
|
119
|
+
let parsed;
|
|
120
|
+
try {
|
|
121
|
+
parsed = JSON.parse(result.stdout);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
throw new AicLaneError("aic logs tx did not print JSON", { cause: error });
|
|
125
|
+
}
|
|
126
|
+
if (!Array.isArray(parsed)) {
|
|
127
|
+
throw new AicLaneError("aic logs tx did not print a JSON array");
|
|
128
|
+
}
|
|
129
|
+
return parsed;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
return {
|
|
133
|
+
describe,
|
|
134
|
+
async getToken(request) {
|
|
135
|
+
const { name } = await describe();
|
|
136
|
+
const whoami = await io.aic([...noPrompt, "whoami", "--token", "--tenant", name]);
|
|
137
|
+
const during = request.reason === "rejected" ? " while refreshing the bearer" : "";
|
|
138
|
+
if (whoami.status !== 0) {
|
|
139
|
+
throw new AicLaneError(`aic whoami --token failed${during} (status ${whoami.status}): ${trim(whoami.stderr || whoami.stdout)}. Unlock the agent (aic login) or pass a reachable tenant.`);
|
|
140
|
+
}
|
|
141
|
+
const token = whoami.stdout.trim();
|
|
142
|
+
if (token.length === 0) {
|
|
143
|
+
throw new AicLaneError(`aic whoami --token printed an empty token${during}`);
|
|
144
|
+
}
|
|
145
|
+
return token;
|
|
146
|
+
},
|
|
147
|
+
logs,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
async function chooseContext(io, noPrompt, tenant) {
|
|
151
|
+
const list = await io.aic([...noPrompt, "ctx", "list", "--json"]);
|
|
152
|
+
if (list.status !== 0) {
|
|
153
|
+
throw new AicLaneError(`aic ctx list failed (status ${list.status}): ${trim(list.stderr || list.stdout)}`);
|
|
154
|
+
}
|
|
155
|
+
const tenants = parseCtxList(list.stdout);
|
|
156
|
+
const chosen = tenant !== undefined
|
|
157
|
+
? tenants.find((row) => row.name === tenant)
|
|
158
|
+
: tenants.find((row) => row.current) ?? tenants[0];
|
|
159
|
+
if (chosen === undefined) {
|
|
160
|
+
throw new AicLaneError(tenant === undefined
|
|
161
|
+
? "aic ctx list returned no tenants"
|
|
162
|
+
: `aic ctx list has no tenant named ${JSON.stringify(tenant)}`);
|
|
163
|
+
}
|
|
164
|
+
return { name: chosen.name, baseUrl: stripSlash(chosen.base_url) };
|
|
165
|
+
}
|
|
166
|
+
function formatLogsFailure(result) {
|
|
167
|
+
const body = (result.stderr || result.stdout).trim();
|
|
168
|
+
const hint = "Unlock the agent (`aic login`) if it is locked.";
|
|
169
|
+
if (body.length === 0) {
|
|
170
|
+
return `aic logs tx failed (status ${result.status}). ${hint}`;
|
|
171
|
+
}
|
|
172
|
+
if (/locked|aic login|session login/i.test(body)) {
|
|
173
|
+
return body;
|
|
174
|
+
}
|
|
175
|
+
return `${body}\n${hint}`;
|
|
176
|
+
}
|
|
177
|
+
function parseCtxList(stdout) {
|
|
178
|
+
let parsed;
|
|
179
|
+
try {
|
|
180
|
+
parsed = JSON.parse(stdout);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
184
|
+
throw new AicLaneError(`aic ctx list --json is not JSON: ${message}`);
|
|
185
|
+
}
|
|
186
|
+
if (!Array.isArray(parsed)) {
|
|
187
|
+
throw new AicLaneError("aic ctx list --json is not an array");
|
|
188
|
+
}
|
|
189
|
+
const rows = [];
|
|
190
|
+
for (const item of parsed) {
|
|
191
|
+
if (!isPlainObject(item) || typeof item.name !== "string" || typeof item.base_url !== "string") {
|
|
192
|
+
throw new AicLaneError("aic ctx list --json row is missing name/base_url");
|
|
193
|
+
}
|
|
194
|
+
rows.push({
|
|
195
|
+
current: item.current === true,
|
|
196
|
+
name: item.name,
|
|
197
|
+
base_url: item.base_url,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return rows;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Ask the provider for a replacement bearer and adopt it.
|
|
204
|
+
*
|
|
205
|
+
* The history below is the `aic` provider's, and it is why a 401 must reach
|
|
206
|
+
* the provider as `"rejected"`: a provider that caches has to know the token
|
|
207
|
+
* it holds is dead, rather than handing it back.
|
|
208
|
+
*
|
|
209
|
+
* MEASURED 2026-09-14 over 20 minutes: `aic whoami --token` handed back a
|
|
210
|
+
* token that worked *right now* and said nothing about how much life was left
|
|
211
|
+
* in it. The first token this probe was given was rejected 401 five minutes
|
|
212
|
+
* later, while a token fetched moments after it stayed good for the next ten —
|
|
213
|
+
* the agent rotated on its own ~898s schedule, so what you got depended on
|
|
214
|
+
* where in that cycle you asked.
|
|
215
|
+
*
|
|
216
|
+
* `aic` now guarantees `--token` at least 840 of those seconds, minting when
|
|
217
|
+
* the cached token is below the floor (MEASURED 2026-09-15: with 823s left the
|
|
218
|
+
* TTL jumped to 899 across the call, and with 864s left it did not move). That
|
|
219
|
+
* removes the reason this refresh usually fired, and none of the reasons it
|
|
220
|
+
* must stay: a file may run longer than the floor, the tenant may revoke, and
|
|
221
|
+
* an older `aic` on someone else's PATH makes no such promise. So the 401 retry
|
|
222
|
+
* remains the correctness mechanism and the floor is only what makes it rare.
|
|
223
|
+
*/
|
|
224
|
+
export async function refreshSessionToken(session) {
|
|
225
|
+
session.token = await session.provider.getToken({
|
|
226
|
+
reason: "rejected",
|
|
227
|
+
rejected: session.token,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
export function realmJsonPath(realm) {
|
|
231
|
+
return `/am/json/realms/root/realms/${realm}`;
|
|
232
|
+
}
|
|
233
|
+
export async function amRequest(io, session, req) {
|
|
234
|
+
const first = await sendAmRequest(io, session, req);
|
|
235
|
+
// A 401 on an authenticated config call means the bearer died under us, not
|
|
236
|
+
// that the request was wrong — so refresh once and retry. Anonymous calls
|
|
237
|
+
// are excluded deliberately: `/authenticate` answers a journey that reached
|
|
238
|
+
// no declared outcome with a bare 401, and that is a verdict, not an
|
|
239
|
+
// expired token.
|
|
240
|
+
if (first.status !== 401 || req.anonymous === true) {
|
|
241
|
+
return first;
|
|
242
|
+
}
|
|
243
|
+
await refreshSessionToken(session);
|
|
244
|
+
return sendAmRequest(io, session, req);
|
|
245
|
+
}
|
|
246
|
+
async function sendAmRequest(io, session, req) {
|
|
247
|
+
const url = `${session.baseUrl}${req.path}`;
|
|
248
|
+
const headers = [
|
|
249
|
+
["Accept", "application/json"],
|
|
250
|
+
["Content-Type", "application/json"],
|
|
251
|
+
...(req.headers ?? []),
|
|
252
|
+
];
|
|
253
|
+
if (!req.anonymous) {
|
|
254
|
+
headers.push(["Authorization", `Bearer ${session.token}`]);
|
|
255
|
+
headers.push(["X-Requested-With", "XMLHttpRequest"]);
|
|
256
|
+
}
|
|
257
|
+
const response = await io.http({
|
|
258
|
+
url,
|
|
259
|
+
method: req.method,
|
|
260
|
+
headerLines: headers,
|
|
261
|
+
...(req.body !== undefined ? { body: req.body } : {}),
|
|
262
|
+
...(req.timeoutMs !== undefined ? { timeoutMs: req.timeoutMs } : {}),
|
|
263
|
+
});
|
|
264
|
+
const transactionId = headerValues(response.headers, "x-forgerock-transactionid")[0];
|
|
265
|
+
const bodyText = redact(response.body, session);
|
|
266
|
+
let body = null;
|
|
267
|
+
if (response.body.length > 0) {
|
|
268
|
+
try {
|
|
269
|
+
body = JSON.parse(response.body);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
body = bodyText;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
status: response.status,
|
|
277
|
+
bodyText,
|
|
278
|
+
body,
|
|
279
|
+
...(transactionId !== undefined ? { transactionId } : {}),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
export function redact(text, session) {
|
|
283
|
+
let out = text;
|
|
284
|
+
const secrets = [session.token, session.baseUrl, hostnameOf(session.baseUrl)];
|
|
285
|
+
for (const secret of secrets) {
|
|
286
|
+
if (secret.length === 0) {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
out = out.split(secret).join("<redacted>");
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
function hostnameOf(baseUrl) {
|
|
294
|
+
try {
|
|
295
|
+
return new URL(baseUrl).hostname;
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return "";
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function stripSlash(url) {
|
|
302
|
+
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
303
|
+
}
|
|
304
|
+
function trim(text) {
|
|
305
|
+
const sliced = text.trim().replace(/\s+/g, " ");
|
|
306
|
+
return sliced.length > 400 ? `${sliced.slice(0, 400)}…` : sliced;
|
|
307
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One AIC-lane run's transaction ids, parked for the vitest hook to pick up
|
|
3
|
+
* if the test fails. Local-only tests never write one of these.
|
|
4
|
+
*/
|
|
5
|
+
export interface AicTrace {
|
|
6
|
+
stem: string;
|
|
7
|
+
passIds: string[];
|
|
8
|
+
tenantName: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function noteAicTrace(trace: AicTrace): void;
|
|
11
|
+
export declare function appendAicPass(id: string): void;
|
|
12
|
+
export declare function peekAicTrace(): AicTrace | undefined;
|
|
13
|
+
export declare function takeAicTrace(): AicTrace | undefined;
|
|
14
|
+
export declare function clearAicTrace(): void;
|
|
15
|
+
/** Subject chain: numbered passes under one stem. */
|
|
16
|
+
export declare function beginTrace(tenantName: string, stem?: string): {
|
|
17
|
+
stem: string;
|
|
18
|
+
next: () => string;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* A one-off authenticate that is not a numbered subject pass (the session
|
|
22
|
+
* minter). Published so a mint failure is still look-up-able; the subject
|
|
23
|
+
* trace replaces it if minting succeeds.
|
|
24
|
+
*/
|
|
25
|
+
export declare function beginSingleTrace(tenantName: string, id?: string): string;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { newStem, passId } from "./txid.js";
|
|
2
|
+
let current;
|
|
3
|
+
export function noteAicTrace(trace) {
|
|
4
|
+
current = {
|
|
5
|
+
stem: trace.stem,
|
|
6
|
+
passIds: [...trace.passIds],
|
|
7
|
+
tenantName: trace.tenantName,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export function appendAicPass(id) {
|
|
11
|
+
if (current === undefined) {
|
|
12
|
+
throw new Error("rhino-local: appendAicPass without an active AIC trace");
|
|
13
|
+
}
|
|
14
|
+
current.passIds.push(id);
|
|
15
|
+
}
|
|
16
|
+
export function peekAicTrace() {
|
|
17
|
+
if (current === undefined) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
stem: current.stem,
|
|
22
|
+
passIds: [...current.passIds],
|
|
23
|
+
tenantName: current.tenantName,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function takeAicTrace() {
|
|
27
|
+
const out = peekAicTrace();
|
|
28
|
+
current = undefined;
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
export function clearAicTrace() {
|
|
32
|
+
current = undefined;
|
|
33
|
+
}
|
|
34
|
+
/** Subject chain: numbered passes under one stem. */
|
|
35
|
+
export function beginTrace(tenantName, stem = newStem()) {
|
|
36
|
+
noteAicTrace({ stem, passIds: [], tenantName });
|
|
37
|
+
let step = 0;
|
|
38
|
+
return {
|
|
39
|
+
stem,
|
|
40
|
+
next() {
|
|
41
|
+
step += 1;
|
|
42
|
+
const id = passId(stem, step);
|
|
43
|
+
appendAicPass(id);
|
|
44
|
+
return id;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A one-off authenticate that is not a numbered subject pass (the session
|
|
50
|
+
* minter). Published so a mint failure is still look-up-able; the subject
|
|
51
|
+
* trace replaces it if minting succeeds.
|
|
52
|
+
*/
|
|
53
|
+
export function beginSingleTrace(tenantName, id = newStem()) {
|
|
54
|
+
noteAicTrace({ stem: id, passIds: [id], tenantName });
|
|
55
|
+
return id;
|
|
56
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AM's `/monitoring/logs?transactionId=` query is a prefix match
|
|
3
|
+
* (`docs/api/08-logs.md`). Unpadded `-1` silently absorbs `-10`. Two-digit
|
|
4
|
+
* padding keeps each pass separately addressable; the cap is the cost.
|
|
5
|
+
*/
|
|
6
|
+
export declare const MAX_PASSES = 99;
|
|
7
|
+
export declare const PASS_DIGITS = 2;
|
|
8
|
+
/** Request header AM accepts; the supplied value wins (`docs/api/02-headers-and-versioning.md`). */
|
|
9
|
+
export declare const TX_HEADER = "x-forgerock-transactionid";
|
|
10
|
+
export declare function newStem(): string;
|
|
11
|
+
export declare function passId(stem: string, step: number): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* AM's `/monitoring/logs?transactionId=` query is a prefix match
|
|
4
|
+
* (`docs/api/08-logs.md`). Unpadded `-1` silently absorbs `-10`. Two-digit
|
|
5
|
+
* padding keeps each pass separately addressable; the cap is the cost.
|
|
6
|
+
*/
|
|
7
|
+
export const MAX_PASSES = 99;
|
|
8
|
+
export const PASS_DIGITS = 2;
|
|
9
|
+
/** Request header AM accepts; the supplied value wins (`docs/api/02-headers-and-versioning.md`). */
|
|
10
|
+
export const TX_HEADER = "x-forgerock-transactionid";
|
|
11
|
+
export function newStem() {
|
|
12
|
+
return randomUUID();
|
|
13
|
+
}
|
|
14
|
+
export function passId(stem, step) {
|
|
15
|
+
if (stem.length === 0) {
|
|
16
|
+
throw new Error("rhino-local: transaction stem must be non-empty");
|
|
17
|
+
}
|
|
18
|
+
if (!Number.isInteger(step) || step < 1) {
|
|
19
|
+
throw new Error(`rhino-local: transaction step must be an integer >= 1, got ${String(step)}`);
|
|
20
|
+
}
|
|
21
|
+
if (step > MAX_PASSES) {
|
|
22
|
+
throw new Error(`rhino-local: transaction step ${step} exceeds ${MAX_PASSES} — two-digit padding would wrap into an id that is a prefix of another`);
|
|
23
|
+
}
|
|
24
|
+
return `${stem}-${String(step).padStart(PASS_DIGITS, "0")}`;
|
|
25
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Case } from "../case/types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Why this case must not be executed on a tenant. `undefined` means the
|
|
4
|
+
* wrapper journey can seed `given` faithfully enough that a green AIC run
|
|
5
|
+
* is evidence, not coincidence.
|
|
6
|
+
*
|
|
7
|
+
* Environment-dependent inputs (`esv` / `secrets` / `managed` / `http`) are
|
|
8
|
+
* skipped rather than silently running against whatever the tenant holds.
|
|
9
|
+
* The one exception is managed state whose fixture-ledger provenance was
|
|
10
|
+
* checked by the caller; later chain passes may then carry records the subject
|
|
11
|
+
* itself created without turning the tenant lane back off.
|
|
12
|
+
*/
|
|
13
|
+
export declare function aicUnsupportedReason(kase: Case, options?: {
|
|
14
|
+
harnessOwnsManaged?: boolean;
|
|
15
|
+
}): string | undefined;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { isPortable } from "../case/portable.js";
|
|
2
|
+
import { ENV_INPUT_KEYS } from "../case/types.js";
|
|
3
|
+
function objectHasKeys(value) {
|
|
4
|
+
return value !== undefined && Object.keys(value).length > 0;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Why this case must not be executed on a tenant. `undefined` means the
|
|
8
|
+
* wrapper journey can seed `given` faithfully enough that a green AIC run
|
|
9
|
+
* is evidence, not coincidence.
|
|
10
|
+
*
|
|
11
|
+
* Environment-dependent inputs (`esv` / `secrets` / `managed` / `http`) are
|
|
12
|
+
* skipped rather than silently running against whatever the tenant holds.
|
|
13
|
+
* The one exception is managed state whose fixture-ledger provenance was
|
|
14
|
+
* checked by the caller; later chain passes may then carry records the subject
|
|
15
|
+
* itself created without turning the tenant lane back off.
|
|
16
|
+
*/
|
|
17
|
+
export function aicUnsupportedReason(kase, options = {}) {
|
|
18
|
+
if (kase.given.engine === "legacy") {
|
|
19
|
+
return "legacy engine: AIC wrapper emit is next-gen only (legacy results go through JavaImporter + Action.send, not callbacksBuilder)";
|
|
20
|
+
}
|
|
21
|
+
if (!isPortable(kase)) {
|
|
22
|
+
const declared = ENV_INPUT_KEYS.filter((key) => hasDeclaredEnv(kase.given, key) &&
|
|
23
|
+
!(key === "managed" && options.harnessOwnsManaged === true));
|
|
24
|
+
if (declared.length > 0) {
|
|
25
|
+
return `given.${declared.join(", ")} is environment-dependent; AIC lane skips rather than run against whatever the tenant holds`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (kase.given.resumedFromSuspend === true) {
|
|
29
|
+
return "given.resumedFromSuspend=true cannot be seeded on a first authenticate";
|
|
30
|
+
}
|
|
31
|
+
if (objectHasKeys(kase.given.secureState)) {
|
|
32
|
+
return "given.secureState: next-gen nodeState has no putSecure";
|
|
33
|
+
}
|
|
34
|
+
if (kase.given.scriptName !== undefined) {
|
|
35
|
+
return "given.scriptName cannot be seeded on AIC (it is the uploaded script's name)";
|
|
36
|
+
}
|
|
37
|
+
if (kase.given.cookieName !== undefined) {
|
|
38
|
+
return "given.cookieName cannot be seeded on AIC (it is a tenant serverinfo value)";
|
|
39
|
+
}
|
|
40
|
+
if (objectHasKeys(kase.given.locales)) {
|
|
41
|
+
return "given.locales cannot be seeded by a setup script";
|
|
42
|
+
}
|
|
43
|
+
if (objectHasKeys(kase.given.bindings)) {
|
|
44
|
+
return "given.bindings cannot be seeded on AIC (those are engine bindings, not nodeState)";
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
function hasDeclaredEnv(given, key) {
|
|
49
|
+
const value = given[key];
|
|
50
|
+
if (value === undefined || value === null) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
if (Array.isArray(value)) {
|
|
54
|
+
return value.length > 0;
|
|
55
|
+
}
|
|
56
|
+
if (typeof value === "object") {
|
|
57
|
+
return Object.keys(value).length > 0;
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boxed value types AM permits that its declared `allowLists` does not name.
|
|
3
|
+
*
|
|
4
|
+
* NOT from the descriptor, and kept separate from it so the audit trail shows
|
|
5
|
+
* which half of the policy is documented and which is not.
|
|
6
|
+
*
|
|
7
|
+
* The descriptor names `java.lang.Byte`, `Character`, `Float`, `Long`,
|
|
8
|
+
* `Number` and `Short` — but not `Double`, `Integer`, `Boolean` or `String`.
|
|
9
|
+
* That reads as an omission in AM's descriptor rather than a policy, because
|
|
10
|
+
* enforcing it would break rows the live matrix records as WORKING:
|
|
11
|
+
*
|
|
12
|
+
* - `java.util.Collections.*` is a live ✅ on the decision node, yet
|
|
13
|
+
* `Collections.singletonMap("a", 1).get("a")` needs `java.lang.Double` —
|
|
14
|
+
* Rhino converts a JS number to a Double. Without it the call fails with
|
|
15
|
+
* `Access to Java class "java.lang.Double" is prohibited`.
|
|
16
|
+
* - An index loop is what `docs/api/12-script-bindings-matrix.md` calls "the
|
|
17
|
+
* portable answer" for a Java List, and `list.get(0)` on a list of strings
|
|
18
|
+
* hands back a `java.lang.String`.
|
|
19
|
+
*
|
|
20
|
+
* The same page records the matching anomaly in the other context:
|
|
21
|
+
* `java.lang.String` and `java.lang.Integer` resolve in validate-scope although
|
|
22
|
+
* neither is declared there — "the rule behind that split is not established".
|
|
23
|
+
*
|
|
24
|
+
* Scoped to the types Rhino produces converting JS primitives across the
|
|
25
|
+
* boundary, rather than a speculative widening.
|
|
26
|
+
*
|
|
27
|
+
* STATUS: inferred from live-recorded rows, NOT directly measured on a decision
|
|
28
|
+
* node. `packages/rhino-local` has a probe ready; measure and then either
|
|
29
|
+
* fold these into the descriptor's story or drop them.
|
|
30
|
+
*/
|
|
31
|
+
export declare const OBSERVED_BASE: readonly string[];
|
|
32
|
+
/**
|
|
33
|
+
* The scripted decision node's Java class allow-list: the descriptor's declared
|
|
34
|
+
* entries plus `OBSERVED_BASE`.
|
|
35
|
+
*
|
|
36
|
+
* Read from the binding descriptor this project already extracted from AM
|
|
37
|
+
* (`docs/api/bindings/scripted-decision-next.json`), so the policy has one
|
|
38
|
+
* source and cannot drift from the document the rest of the harness generates
|
|
39
|
+
* its mocks from. Not hand-transcribed: a copied list is a list that goes
|
|
40
|
+
* stale silently.
|
|
41
|
+
*/
|
|
42
|
+
export declare function decisionNodeClassAllowList(): string[];
|