@manyoars/oars 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,58 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ /** One step's `Idempotency-Key`: the lowercase SHA-256 hex of `<base>:<step>`, the 64-hex form every aor-common interceptor accepts. */
5
+ export const stepKey = (base, step) => createHash('sha256').update(`${base}:${step}`).digest('hex');
6
+ const isRecord = (value) => {
7
+ if (typeof value !== 'object' || value === null)
8
+ return false;
9
+ const base = Reflect.get(value, 'base');
10
+ const steps = Reflect.get(value, 'steps');
11
+ return (typeof base === 'string' &&
12
+ Array.isArray(steps) &&
13
+ steps.every((entry) => typeof entry === 'object' && entry !== null && typeof Reflect.get(entry, 'step') === 'string' && typeof Reflect.get(entry, 'status') === 'number'));
14
+ };
15
+ const isMissing = (error) => error instanceof Error && 'code' in error && error.code === 'ENOENT';
16
+ /**
17
+ * Keeps one file per base key in `<dir>/oars/journal/`, named by the key's hash because the key is whatever the developer typed.
18
+ * A write goes to a sibling file and is renamed over the old one; a torn file reads as no record, and the platform's replay
19
+ * makes the rerun correct either way.
20
+ */
21
+ export const fileJournalStore = (dir) => {
22
+ const folder = join(dir, 'oars', 'journal');
23
+ const fileFor = (base) => join(folder, `${createHash('sha256').update(base).digest('hex')}.json`);
24
+ return {
25
+ read: async (base) => {
26
+ let text;
27
+ try {
28
+ text = await readFile(fileFor(base), 'utf8');
29
+ }
30
+ catch (error) {
31
+ if (isMissing(error))
32
+ return undefined;
33
+ throw error;
34
+ }
35
+ try {
36
+ const parsed = JSON.parse(text);
37
+ return isRecord(parsed) && parsed.base === base ? parsed : undefined;
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ },
43
+ write: async (record) => {
44
+ await mkdir(folder, { recursive: true, mode: 0o700 });
45
+ const file = fileFor(record.base);
46
+ const temp = `${file}.tmp`;
47
+ try {
48
+ await writeFile(temp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
49
+ await rename(temp, file);
50
+ }
51
+ catch (error) {
52
+ await rm(temp, { force: true });
53
+ throw error;
54
+ }
55
+ },
56
+ delete: (base) => rm(fileFor(base), { force: true }),
57
+ };
58
+ };
package/dist/oidc.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import * as client from 'openid-client';
2
+ import { type RetryDeps } from './retry.js';
3
+ /** The public Keycloak client T67 registers for the CLI: device authorization grant only. */
4
+ export declare const CLIENT_ID = "oars-cli";
5
+ export interface OidcContext extends RetryDeps {
6
+ /** The CLI's version, sent as `User-Agent: oars/<version>`. */
7
+ version: string;
8
+ /** Names where a failure happened in `--json` documents. */
9
+ step: string;
10
+ }
11
+ /** What the CLI keeps of a Keycloak token response. */
12
+ export interface KeycloakTokens {
13
+ accessToken: string;
14
+ /** Absent when the realm did not rotate it. */
15
+ refreshToken?: string;
16
+ }
17
+ /** Keycloak discovery for the realm at `issuerUrl`. Plain http is accepted for a loopback issuer only (the local stack). */
18
+ export declare const discover: (issuerUrl: string, context: OidcContext) => Promise<client.Configuration>;
19
+ /** RFC 8628: asks Keycloak for a device and user code. */
20
+ export declare const startDeviceAuthorization: (config: client.Configuration, step: string) => Promise<client.DeviceAuthorizationResponse>;
21
+ /** Polls until the developer approves the code, honoring `slow_down`; a denied or expired code is an exit-3 failure. */
22
+ export declare const awaitDeviceTokens: (config: client.Configuration, device: client.DeviceAuthorizationResponse, step: string) => Promise<KeycloakTokens>;
23
+ /** Exchanges the stored refresh token. Keycloak rotates it, so the caller persists the new one before using the access token. */
24
+ export declare const refreshTokens: (config: client.Configuration, refreshToken: string, step: string) => Promise<KeycloakTokens>;
25
+ /** RFC 7009: revokes the refresh token, which ends the Keycloak session behind it. */
26
+ export declare const revokeRefreshToken: (config: client.Configuration, refreshToken: string, step: string) => Promise<void>;
package/dist/oidc.js ADDED
@@ -0,0 +1,137 @@
1
+ import * as client from 'openid-client';
2
+ import { CliError, ExitCode, unreachableError } from './errors.js';
3
+ import { retryingFetch } from './retry.js';
4
+ /** The public Keycloak client T67 registers for the CLI: device authorization grant only. */
5
+ export const CLIENT_ID = 'oars-cli';
6
+ const SCOPE = 'openid email profile';
7
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
8
+ /**
9
+ * Every call to Keycloak sends `User-Agent: oars/<version>` and gets design §3.2's retries, except the token endpoint:
10
+ * it rotates the refresh token, so a request it may have processed is never repeated blindly (device polling repeats
11
+ * by itself). `isTokenEndpoint` is told which URL that is once discovery has found it. A 502-504 or 429 that is still the
12
+ * answer once the retries are spent (or the token endpoint's single attempt) is design §3.2's exit 8 or 7 here, because
13
+ * handed on, the library would call it an unexpected status (exit 1).
14
+ */
15
+ const keycloakFetch = ({ version, step, sleep, random }, isTokenEndpoint) => {
16
+ const retrying = retryingFetch((request) => fetch(request), { sleep, random });
17
+ return async (url, options) => {
18
+ const request = new Request(url, { ...options, headers: { ...options.headers, 'user-agent': `oars/${version}` } });
19
+ let response;
20
+ try {
21
+ response = await (isTokenEndpoint(url) ? fetch(request) : retrying(request));
22
+ }
23
+ catch (error) {
24
+ throw unreachableError(step, error);
25
+ }
26
+ const { status } = response;
27
+ if (status === 429 || status === 502 || status === 503 || status === 504) {
28
+ void response.body?.cancel().catch(() => undefined);
29
+ if (status === 429) {
30
+ const title = 'Keycloak is rate limiting this client. Wait a moment, then run the command again.';
31
+ throw new CliError(title, ExitCode.RATE_LIMITED, step, { code: 'RATE_LIMITED', title, status });
32
+ }
33
+ throw unreachableError(step, new Error(`Keycloak answered HTTP ${String(status)}`));
34
+ }
35
+ return response;
36
+ };
37
+ };
38
+ /** The CliError the library wrapped on its way out of our fetch, if any (it puts an unrecognized error in `cause`). */
39
+ const wrapped = (error) => {
40
+ for (let current = error, depth = 0; current instanceof Error && depth < 5; current = current.cause, depth += 1) {
41
+ if (current instanceof CliError)
42
+ return current;
43
+ }
44
+ return undefined;
45
+ };
46
+ // OAuth errors that mean "sign in again" (exit 3), with the line the developer sees.
47
+ const SIGN_IN_AGAIN = new Map([
48
+ ['invalid_grant', 'Your login has expired or was revoked. Run "oars login" to sign in again.'],
49
+ ['expired_token', 'The device code expired before it was approved. Run "oars login" again.'],
50
+ ['access_denied', 'Access was denied. Run "oars login" to try again.'],
51
+ ]);
52
+ const signInAgain = (step, error) => {
53
+ const title = SIGN_IN_AGAIN.get(error) ?? error;
54
+ return new CliError(title, ExitCode.AUTH, step, { code: error.toUpperCase(), title });
55
+ };
56
+ const failure = (step, error) => {
57
+ const own = wrapped(error);
58
+ if (own !== undefined)
59
+ return own;
60
+ if (error instanceof client.ResponseBodyError) {
61
+ if (SIGN_IN_AGAIN.has(error.error))
62
+ return signInAgain(step, error.error);
63
+ const title = `Keycloak refused the request: ${error.error_description ?? error.error}`;
64
+ return new CliError(title, ExitCode.UNEXPECTED, step, { code: error.error.toUpperCase(), title });
65
+ }
66
+ if (error instanceof client.ClientError && error.code === 'OAUTH_TIMEOUT')
67
+ return unreachableError(step, error);
68
+ return new CliError(error instanceof Error ? error.message : String(error), ExitCode.UNEXPECTED, step);
69
+ };
70
+ const usage = (message) => new CliError(message, ExitCode.USAGE, 'usage');
71
+ /** Keycloak discovery for the realm at `issuerUrl`. Plain http is accepted for a loopback issuer only (the local stack). */
72
+ export const discover = async (issuerUrl, context) => {
73
+ let issuer;
74
+ try {
75
+ issuer = new URL(issuerUrl);
76
+ }
77
+ catch {
78
+ throw usage(`The issuer URL "${issuerUrl}" is not a URL.`);
79
+ }
80
+ const loopback = issuer.protocol === 'http:' && LOOPBACK_HOSTS.has(issuer.hostname);
81
+ if (issuer.protocol !== 'https:' && !loopback)
82
+ throw usage(`The issuer URL must use https (http is accepted for localhost only): ${issuerUrl}`);
83
+ let tokenEndpoint;
84
+ try {
85
+ const config = await client.discovery(issuer, CLIENT_ID, undefined, client.None(), {
86
+ [client.customFetch]: keycloakFetch(context, (url) => tokenEndpoint !== undefined && new URL(url).href === tokenEndpoint),
87
+ execute: loopback ? [client.allowInsecureRequests] : [],
88
+ });
89
+ const endpoint = config.serverMetadata().token_endpoint;
90
+ tokenEndpoint = endpoint === undefined ? undefined : new URL(endpoint).href;
91
+ return config;
92
+ }
93
+ catch (error) {
94
+ throw failure(context.step, error);
95
+ }
96
+ };
97
+ const tokensOf = (response) => response.refresh_token === undefined ? { accessToken: response.access_token } : { accessToken: response.access_token, refreshToken: response.refresh_token };
98
+ /** RFC 8628: asks Keycloak for a device and user code. */
99
+ export const startDeviceAuthorization = async (config, step) => {
100
+ try {
101
+ return await client.initiateDeviceAuthorization(config, { scope: SCOPE });
102
+ }
103
+ catch (error) {
104
+ throw failure(step, error);
105
+ }
106
+ };
107
+ /** Polls until the developer approves the code, honoring `slow_down`; a denied or expired code is an exit-3 failure. */
108
+ export const awaitDeviceTokens = async (config, device, step) => {
109
+ try {
110
+ return tokensOf(await client.pollDeviceAuthorizationGrant(config, device));
111
+ }
112
+ catch (error) {
113
+ // The library stops polling at the code's `expires_in`; that deadline is the expired code, not a network timeout.
114
+ if (error instanceof client.ClientError && error.code === 'OAUTH_TIMEOUT') {
115
+ throw signInAgain(step, 'expired_token');
116
+ }
117
+ throw failure(step, error);
118
+ }
119
+ };
120
+ /** Exchanges the stored refresh token. Keycloak rotates it, so the caller persists the new one before using the access token. */
121
+ export const refreshTokens = async (config, refreshToken, step) => {
122
+ try {
123
+ return tokensOf(await client.refreshTokenGrant(config, refreshToken));
124
+ }
125
+ catch (error) {
126
+ throw failure(step, error);
127
+ }
128
+ };
129
+ /** RFC 7009: revokes the refresh token, which ends the Keycloak session behind it. */
130
+ export const revokeRefreshToken = async (config, refreshToken, step) => {
131
+ try {
132
+ await client.tokenRevocation(config, refreshToken);
133
+ }
134
+ catch (error) {
135
+ throw failure(step, error);
136
+ }
137
+ };
@@ -0,0 +1,11 @@
1
+ export interface RetryDeps {
2
+ sleep: (ms: number) => Promise<void>;
3
+ /** In [0, 1); scales each delay between half and one and a half times its base. */
4
+ random: () => number;
5
+ }
6
+ /**
7
+ * Wraps a fetch so a transient failure is retried three times with jittered exponential backoff, never sooner than the
8
+ * server's Retry-After. Every attempt sends a clone, so the body and the Idempotency-Key repeat exactly. After the last
9
+ * retry the final response is returned, or the final network error thrown.
10
+ */
11
+ export declare const retryingFetch: (send: (request: Request) => Promise<Response>, { sleep, random }: RetryDeps) => (request: Request) => Promise<Response>;
package/dist/retry.js ADDED
@@ -0,0 +1,40 @@
1
+ const MAX_RETRIES = 3;
2
+ const BASE_DELAY_MS = 500;
3
+ /** Seconds, or an HTTP date, from a Retry-After header; undefined when absent or unreadable. */
4
+ const retryAfterMs = (response) => {
5
+ const header = response.headers.get('Retry-After');
6
+ if (header === null || header.trim() === '')
7
+ return undefined;
8
+ const seconds = Number(header);
9
+ if (Number.isFinite(seconds) && seconds >= 0)
10
+ return seconds * 1000;
11
+ const date = Date.parse(header);
12
+ return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
13
+ };
14
+ /** Design §3.2: network errors, 502-504 and a 429 that names a wait are retried; nothing else is. */
15
+ const worthRetrying = (response) => response.status === 502 || response.status === 503 || response.status === 504 || (response.status === 429 && retryAfterMs(response) !== undefined);
16
+ /**
17
+ * Wraps a fetch so a transient failure is retried three times with jittered exponential backoff, never sooner than the
18
+ * server's Retry-After. Every attempt sends a clone, so the body and the Idempotency-Key repeat exactly. After the last
19
+ * retry the final response is returned, or the final network error thrown.
20
+ */
21
+ export const retryingFetch = (send, { sleep, random }) => async (request) => {
22
+ for (let retry = 0;; retry += 1) {
23
+ const backoff = BASE_DELAY_MS * 2 ** retry * (0.5 + random());
24
+ let response;
25
+ try {
26
+ response = await send(request.clone());
27
+ }
28
+ catch (error) {
29
+ if (retry === MAX_RETRIES)
30
+ throw error;
31
+ await sleep(backoff);
32
+ continue;
33
+ }
34
+ if (retry === MAX_RETRIES || !worthRetrying(response))
35
+ return response;
36
+ // Not awaited: on a cloned body, cancel() settles only once the other branch is cancelled too.
37
+ void response.body?.cancel().catch(() => undefined);
38
+ await sleep(Math.max(backoff, retryAfterMs(response) ?? 0));
39
+ }
40
+ };
@@ -0,0 +1,12 @@
1
+ import type { components } from '@manyoars/oars-client';
2
+ import type { RunDeps } from './cli.js';
3
+ export type LoginSuccess = components['schemas']['LoginSuccess'];
4
+ /** `POST /auth/oidc/exchange`: the platform session for a Keycloak access token. The first exchange provisions DEVELOPER (T60). */
5
+ export declare const exchangeKeycloakToken: (deps: RunDeps, apiUrl: string, keycloakAccessToken: string, step: string) => Promise<LoginSuccess>;
6
+ /**
7
+ * The bearer token for an API call to `apiUrl`. `OARS_API_KEY` wins over a stored login and touches no network; otherwise the
8
+ * stored access token while it is valid, else one refreshed through Keycloak and exchanged. A stored login belongs to the API
9
+ * it was made against: for any other `apiUrl` the call exits 2 and sends nothing, so one deployment's refresh token never
10
+ * reaches another host.
11
+ */
12
+ export declare const resolveAuthToken: (deps: RunDeps, apiUrl: string) => Promise<string>;
@@ -0,0 +1,41 @@
1
+ import { apiClient, unwrap } from './api.js';
2
+ import { CliError, ExitCode } from './errors.js';
3
+ import { discover, refreshTokens } from './oidc.js';
4
+ /** Treat a token that expires within this window as expired, so it cannot lapse between the check and the call. */
5
+ const EXPIRY_SKEW_MS = 30_000;
6
+ /** `POST /auth/oidc/exchange`: the platform session for a Keycloak access token. The first exchange provisions DEVELOPER (T60). */
7
+ export const exchangeKeycloakToken = async (deps, apiUrl, keycloakAccessToken, step) => {
8
+ const client = apiClient(deps, { apiUrl, step });
9
+ return unwrap(step, await client.POST('/auth/oidc/exchange', { body: { accessToken: keycloakAccessToken } }));
10
+ };
11
+ const sameUrl = (a, b) => a.replace(/\/+$/, '') === b.replace(/\/+$/, '');
12
+ const refreshLogin = async (deps, stored) => {
13
+ const step = 'auth';
14
+ const config = await discover(stored.issuerUrl, { ...deps, step });
15
+ const tokens = await refreshTokens(config, stored.refreshToken, step);
16
+ // Keycloak rotates the refresh token on every use, so the old one is already dead: save the new one before anything can fail.
17
+ const rotated = { ...stored, refreshToken: tokens.refreshToken ?? stored.refreshToken };
18
+ await deps.store.write(rotated);
19
+ const session = await exchangeKeycloakToken(deps, stored.apiUrl, tokens.accessToken, step);
20
+ await deps.store.write({ ...rotated, accessToken: session.accessToken, expiresAt: session.expiresAt });
21
+ return session.accessToken;
22
+ };
23
+ /**
24
+ * The bearer token for an API call to `apiUrl`. `OARS_API_KEY` wins over a stored login and touches no network; otherwise the
25
+ * stored access token while it is valid, else one refreshed through Keycloak and exchanged. A stored login belongs to the API
26
+ * it was made against: for any other `apiUrl` the call exits 2 and sends nothing, so one deployment's refresh token never
27
+ * reaches another host.
28
+ */
29
+ export const resolveAuthToken = async (deps, apiUrl) => {
30
+ const key = deps.env.OARS_API_KEY;
31
+ if (key !== undefined && key !== '')
32
+ return key;
33
+ const stored = await deps.store.read();
34
+ if (stored === undefined)
35
+ throw new CliError('Not logged in. Run "oars login", or set OARS_API_KEY.', ExitCode.AUTH, 'auth');
36
+ if (!sameUrl(stored.apiUrl, apiUrl))
37
+ throw new CliError(`not logged in to ${apiUrl}; run oars login --api-url ${apiUrl}`, ExitCode.USAGE, 'auth');
38
+ if (Date.parse(stored.expiresAt) - EXPIRY_SKEW_MS > deps.now())
39
+ return stored.accessToken;
40
+ return refreshLogin(deps, stored);
41
+ };
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@manyoars/oars",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "The oars CLI: log in to Many Oars, list the starter templates and deploy an agent",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/manyoars/oars.git",
9
+ "directory": "packages/cli"
10
+ },
11
+ "type": "module",
12
+ "engines": {
13
+ "node": ">=24"
14
+ },
15
+ "bin": {
16
+ "oars": "./dist/bin.js"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "parity/deploy-plans"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "scripts": {
26
+ "build": "node scripts/build.mjs",
27
+ "prepack": "npm run build",
28
+ "typecheck": "tsc --noEmit",
29
+ "test": "vitest run",
30
+ "test:live": "vitest run --config vitest.live.config.ts"
31
+ },
32
+ "dependencies": {
33
+ "@manyoars/oars-client": "^0.1.1",
34
+ "commander": "^15.0.0",
35
+ "openid-client": "^6.8.8"
36
+ },
37
+ "devDependencies": {
38
+ "@playwright/test": "^1.64.1",
39
+ "msw": "^3.0.3",
40
+ "typescript": "~5.9.3",
41
+ "yaml": "^2.9.1"
42
+ }
43
+ }
@@ -0,0 +1,34 @@
1
+ [
2
+ {
3
+ "method": "POST",
4
+ "path": "/agents/workloads",
5
+ "body": {
6
+ "name": "Paid API",
7
+ "services": [
8
+ {
9
+ "name": "api",
10
+ "image": "ghcr.io/manyoars/paid-api@sha256:bc18de1da5b0a0fbdc18d6d68aa9da4f89a0ab118a2c18921a1247b09d9d4146",
11
+ "port": 8080,
12
+ "public": true,
13
+ "cpuMillicores": 250,
14
+ "memoryMib": 256
15
+ }
16
+ ]
17
+ }
18
+ },
19
+ {
20
+ "method": "POST",
21
+ "path": "/billing/agents/{workloadId}/prices",
22
+ "body": { "priceRef": "calls-100", "displayName": "100 calls", "amountUsd": "5.00", "kind": "CREDIT_PACK", "credits": 100 }
23
+ },
24
+ {
25
+ "method": "POST",
26
+ "path": "/billing/agents/{workloadId}/prices",
27
+ "body": { "priceRef": "calls-1000", "displayName": "1,000 calls", "amountUsd": "40.00", "kind": "CREDIT_PACK", "credits": 1000 }
28
+ },
29
+ {
30
+ "method": "POST",
31
+ "path": "/agents/workloads/{workloadId}/deploy",
32
+ "body": null
33
+ }
34
+ ]
@@ -0,0 +1,34 @@
1
+ [
2
+ {
3
+ "method": "POST",
4
+ "path": "/agents/workloads",
5
+ "body": {
6
+ "name": "Per-task agent",
7
+ "services": [
8
+ {
9
+ "name": "agent",
10
+ "image": "ghcr.io/manyoars/per-task-agent@sha256:b88bb11504236ad97349f6a4ad820a98c16eef670e9aa2f93c157019406aecd6",
11
+ "port": 8080,
12
+ "public": true,
13
+ "cpuMillicores": 500,
14
+ "memoryMib": 512
15
+ }
16
+ ]
17
+ }
18
+ },
19
+ {
20
+ "method": "POST",
21
+ "path": "/billing/agents/{workloadId}/prices",
22
+ "body": { "priceRef": "task-1", "displayName": "1 task", "amountUsd": "9.00", "kind": "CREDIT_PACK", "credits": 1 }
23
+ },
24
+ {
25
+ "method": "POST",
26
+ "path": "/billing/agents/{workloadId}/prices",
27
+ "body": { "priceRef": "tasks-5", "displayName": "5 tasks", "amountUsd": "40.00", "kind": "CREDIT_PACK", "credits": 5 }
28
+ },
29
+ {
30
+ "method": "POST",
31
+ "path": "/agents/workloads/{workloadId}/deploy",
32
+ "body": null
33
+ }
34
+ ]