@runuai/host 0.9.112 → 0.9.114
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/db/migrations/0020_user_credential_store_ref.sql +6 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +6 -0
- package/lib/codex-auth.ts +79 -3
- package/lib/credential-store-pickup.ts +135 -0
- package/lib/credential-store.ts +77 -0
- package/lib/engine-accounts.ts +137 -20
- package/lib/env.ts +3 -0
- package/lib/github-tokens.ts +117 -1
- package/lib/mcp-connections.ts +138 -4
- package/lib/orchestrator.ts +52 -0
- package/lib/ssh.ts +91 -0
- package/lib/token-broker-lambda.ts +51 -0
- package/lib/token-broker-local.ts +368 -0
- package/lib/token-broker-ssm.ts +185 -0
- package/lib/token-broker.ts +218 -0
- package/lib/token-lanes/openai-oauth.ts +119 -0
- package/lib/token-lanes/registry.ts +45 -0
- package/lib/token-lanes/types.ts +163 -0
- package/lib/token-lanes/xai-oauth.ts +161 -0
- package/lib/user-credentials-store.ts +310 -0
- package/package.json +3 -1
- package/scripts/agent/task-up.sh +31 -1
- package/src/main.ts +31 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lane `xai-oauth` — Grok subscription sign-ins (`grok login --device-auth`).
|
|
3
|
+
*
|
|
4
|
+
* `auth.json` is keyed by `<issuer>::<client-id>`; each entry carries
|
|
5
|
+
* `key` (the bearer the CLI sends), `refresh_token`, `expires_at` (ISO),
|
|
6
|
+
* `oidc_issuer` and `oidc_client_id`. Refresh is a standard OIDC
|
|
7
|
+
* `grant_type=refresh_token` against `https://auth.x.ai/oauth2/token`
|
|
8
|
+
* (confirmed from the issuer's discovery document, 2026-09-09; refresh_token
|
|
9
|
+
* is among its supported grants).
|
|
10
|
+
*
|
|
11
|
+
* ASSUMPTION, recorded until observed on a live refresh: `key` is the OIDC
|
|
12
|
+
* access token, so a refresh replaces it with `access_token`. The lane never
|
|
13
|
+
* writes on an answer that lacks `access_token`, so a wrong assumption costs
|
|
14
|
+
* a failed refresh (retried, then the reactive path), never a corrupted
|
|
15
|
+
* sign-in.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
BROKER_REFRESH_PLACEHOLDER,
|
|
20
|
+
isTerminalOAuthError,
|
|
21
|
+
parseObjectFile,
|
|
22
|
+
type CredentialFiles,
|
|
23
|
+
type CredentialHeader,
|
|
24
|
+
type LaneHttp,
|
|
25
|
+
type RefreshOutcome,
|
|
26
|
+
type TokenLane,
|
|
27
|
+
} from "./types";
|
|
28
|
+
|
|
29
|
+
export const XAI_ISSUER = "https://auth.x.ai";
|
|
30
|
+
export const XAI_TOKEN_ENDPOINT = `${XAI_ISSUER}/oauth2/token`;
|
|
31
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
interface Entry {
|
|
34
|
+
key: string;
|
|
35
|
+
doc: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function entriesOf(files: CredentialFiles): { doc: Record<string, unknown>; entries: Entry[] } | null {
|
|
39
|
+
const doc = parseObjectFile(files["auth.json"]);
|
|
40
|
+
if (!doc) return null;
|
|
41
|
+
const entries: Entry[] = [];
|
|
42
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
43
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
44
|
+
entries.push({ key, doc: value as Record<string, unknown> });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { doc, entries };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function expiryOf(entry: Record<string, unknown>): number | null {
|
|
51
|
+
const raw = entry.expires_at;
|
|
52
|
+
if (typeof raw !== "string") return null;
|
|
53
|
+
const ms = Date.parse(raw);
|
|
54
|
+
return Number.isFinite(ms) ? ms : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const xaiOAuthLane: TokenLane = {
|
|
58
|
+
id: "xai-oauth",
|
|
59
|
+
endpoints: [XAI_TOKEN_ENDPOINT],
|
|
60
|
+
// Observed 2026-09-09: the token lives SIX hours (create_time → expires_at).
|
|
61
|
+
// Refresh with three hours left — the hourly cycle then renews it at about
|
|
62
|
+
// four hours of age — and every four hours when expiry cannot be read.
|
|
63
|
+
schedule: { leadMs: 3 * HOUR_MS, everyMs: 4 * HOUR_MS },
|
|
64
|
+
reactiveSignals: [/invalid_grant/i, /token (?:has )?expired/i, /log ?in again/i, /unauthorized/i],
|
|
65
|
+
|
|
66
|
+
matches(header: CredentialHeader, files: CredentialFiles): boolean {
|
|
67
|
+
if (header.kind !== "grok" || header.authKind !== "config-dir") return false;
|
|
68
|
+
const parsed = entriesOf(files);
|
|
69
|
+
return (
|
|
70
|
+
parsed !== null && parsed.entries.some((e) => typeof e.doc.refresh_token === "string")
|
|
71
|
+
);
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
expiresAt(files: CredentialFiles): number | null {
|
|
75
|
+
const parsed = entriesOf(files);
|
|
76
|
+
if (!parsed) return null;
|
|
77
|
+
const known = parsed.entries.map((e) => expiryOf(e.doc)).filter((v): v is number => v !== null);
|
|
78
|
+
return known.length ? Math.min(...known) : null;
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
brokeredCopy(files: CredentialFiles): CredentialFiles {
|
|
82
|
+
const parsed = entriesOf(files);
|
|
83
|
+
if (!parsed) return files;
|
|
84
|
+
let changed = false;
|
|
85
|
+
for (const entry of parsed.entries) {
|
|
86
|
+
if (
|
|
87
|
+
typeof entry.doc.refresh_token === "string" &&
|
|
88
|
+
entry.doc.refresh_token !== BROKER_REFRESH_PLACEHOLDER
|
|
89
|
+
) {
|
|
90
|
+
entry.doc.refresh_token = BROKER_REFRESH_PLACEHOLDER;
|
|
91
|
+
changed = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return changed ? { ...files, "auth.json": `${JSON.stringify(parsed.doc, null, 2)}\n` } : files;
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
async refresh(files: CredentialFiles, http: LaneHttp, now: number): Promise<RefreshOutcome> {
|
|
98
|
+
const parsed = entriesOf(files);
|
|
99
|
+
if (!parsed) return { ok: false, terminal: true, reason: "auth.json is not readable" };
|
|
100
|
+
let refreshed = 0;
|
|
101
|
+
let expiresAt: number | null = null;
|
|
102
|
+
for (const entry of parsed.entries) {
|
|
103
|
+
const current = entry.doc.refresh_token;
|
|
104
|
+
if (typeof current !== "string" || current === BROKER_REFRESH_PLACEHOLDER) continue;
|
|
105
|
+
const issuer = typeof entry.doc.oidc_issuer === "string" ? entry.doc.oidc_issuer : XAI_ISSUER;
|
|
106
|
+
if (issuer.replace(/\/$/, "") !== XAI_ISSUER) {
|
|
107
|
+
return { ok: false, terminal: true, reason: "unexpected issuer" };
|
|
108
|
+
}
|
|
109
|
+
const clientId = entry.doc.oidc_client_id;
|
|
110
|
+
if (typeof clientId !== "string" || !clientId) {
|
|
111
|
+
return { ok: false, terminal: true, reason: "entry has no client id" };
|
|
112
|
+
}
|
|
113
|
+
let res;
|
|
114
|
+
try {
|
|
115
|
+
res = await http.postForm(XAI_TOKEN_ENDPOINT, {
|
|
116
|
+
grant_type: "refresh_token",
|
|
117
|
+
refresh_token: current,
|
|
118
|
+
client_id: clientId,
|
|
119
|
+
});
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
terminal: false,
|
|
124
|
+
reason: `request failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (res.status < 200 || res.status >= 300) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
terminal: isTerminalOAuthError(res.status, res.json),
|
|
131
|
+
reason: `HTTP ${res.status}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const body =
|
|
135
|
+
res.json && typeof res.json === "object" && !Array.isArray(res.json)
|
|
136
|
+
? (res.json as Record<string, unknown>)
|
|
137
|
+
: null;
|
|
138
|
+
const access = body?.access_token;
|
|
139
|
+
if (typeof access !== "string" || !access) {
|
|
140
|
+
return { ok: false, terminal: false, reason: "response missing access_token" };
|
|
141
|
+
}
|
|
142
|
+
entry.doc.key = access;
|
|
143
|
+
if (typeof body?.refresh_token === "string" && body.refresh_token) {
|
|
144
|
+
entry.doc.refresh_token = body.refresh_token;
|
|
145
|
+
}
|
|
146
|
+
const expiresIn = body?.expires_in;
|
|
147
|
+
if (typeof expiresIn === "number" && Number.isFinite(expiresIn)) {
|
|
148
|
+
const at = now + expiresIn * 1000;
|
|
149
|
+
entry.doc.expires_at = new Date(at).toISOString();
|
|
150
|
+
expiresAt = expiresAt === null ? at : Math.min(expiresAt, at);
|
|
151
|
+
}
|
|
152
|
+
refreshed += 1;
|
|
153
|
+
}
|
|
154
|
+
if (refreshed === 0) return { ok: false, terminal: true, reason: "no refresh token to use" };
|
|
155
|
+
return {
|
|
156
|
+
ok: true,
|
|
157
|
+
files: { ...files, "auth.json": `${JSON.stringify(parsed.doc, null, 2)}\n` },
|
|
158
|
+
expiresAt,
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
};
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-134 slice 2 — per-user credentials on a Uai-operated host reconcile
|
|
3
|
+
* with the credential store at start, the same way engine accounts do
|
|
4
|
+
* (`reconcileEngineAccountsFromStore`):
|
|
5
|
+
*
|
|
6
|
+
* <prefix>/users/<userId>/github GitHub App user token
|
|
7
|
+
* <prefix>/users/<userId>/ssh the user's host-minted keypair
|
|
8
|
+
* <prefix>/users/<userId>/mcp/<connId> an MCP connection
|
|
9
|
+
*
|
|
10
|
+
* - a parameter with no local row is imported (a replacement host inherits
|
|
11
|
+
* every user's credentials without anyone reconnecting);
|
|
12
|
+
* - a local row with no `store_ref` is uploaded (first start with the flag);
|
|
13
|
+
* - a local row whose `store_ref` is under the listed path but absent from
|
|
14
|
+
* it was removed elsewhere — it goes locally too.
|
|
15
|
+
* The write-through mirrors live next to each table's writes (ssh.ts,
|
|
16
|
+
* github-tokens.ts, mcp-connections.ts). Owned hosts: `configuredCredentialStore()`
|
|
17
|
+
* is null and nothing here runs.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
configuredCredentialStore,
|
|
22
|
+
credentialStorePrefix,
|
|
23
|
+
isParameterPathSegment,
|
|
24
|
+
type CredentialStore,
|
|
25
|
+
} from "./credential-store";
|
|
26
|
+
import {
|
|
27
|
+
deleteToken as deleteGithubTokenLocal,
|
|
28
|
+
githubTokenParameterName,
|
|
29
|
+
githubTokenStoreValue,
|
|
30
|
+
importGithubTokenFromStore,
|
|
31
|
+
listGithubTokenStoreRows,
|
|
32
|
+
setGithubTokenStoreRef,
|
|
33
|
+
type StoredGithubToken,
|
|
34
|
+
} from "./github-tokens";
|
|
35
|
+
import {
|
|
36
|
+
deleteMcpConnectionLocal,
|
|
37
|
+
importMcpConnectionFromStore,
|
|
38
|
+
listMcpConnectionStoreRows,
|
|
39
|
+
mcpConnectionParameterName,
|
|
40
|
+
mcpConnectionStoreValue,
|
|
41
|
+
setMcpConnectionStoreRef,
|
|
42
|
+
type StoredMcpConnection,
|
|
43
|
+
} from "./mcp-connections";
|
|
44
|
+
import {
|
|
45
|
+
deleteKey as deleteSshKeyLocal,
|
|
46
|
+
importSshKeyFromStore,
|
|
47
|
+
listSshKeyStoreRows,
|
|
48
|
+
setSshKeyStoreRef,
|
|
49
|
+
sshKeyParameterName,
|
|
50
|
+
sshKeyStoreValue,
|
|
51
|
+
type StoredSshKey,
|
|
52
|
+
} from "./ssh";
|
|
53
|
+
|
|
54
|
+
export interface ReconcileUserCredentialsResult {
|
|
55
|
+
skipped: boolean;
|
|
56
|
+
imported: number;
|
|
57
|
+
uploaded: number;
|
|
58
|
+
removed: number;
|
|
59
|
+
errors: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type ParsedName =
|
|
63
|
+
| { kind: "github"; userId: string }
|
|
64
|
+
| { kind: "ssh"; userId: string }
|
|
65
|
+
| { kind: "mcp"; userId: string; id: string };
|
|
66
|
+
|
|
67
|
+
/** `<prefix>/users/<u>/github|ssh|mcp/<id>`; null for anything else. */
|
|
68
|
+
export function parseUserParameterName(
|
|
69
|
+
name: string,
|
|
70
|
+
prefix: string = credentialStorePrefix(),
|
|
71
|
+
): ParsedName | null {
|
|
72
|
+
if (!name.startsWith(`${prefix}/users/`)) return null;
|
|
73
|
+
const parts = name.slice(prefix.length + 1).split("/");
|
|
74
|
+
if (!parts.every((p) => isParameterPathSegment(p))) return null;
|
|
75
|
+
const [, userId, what, id] = parts;
|
|
76
|
+
if (!userId || !what) return null;
|
|
77
|
+
if (parts.length === 3 && what === "github") return { kind: "github", userId };
|
|
78
|
+
if (parts.length === 3 && what === "ssh") return { kind: "ssh", userId };
|
|
79
|
+
if (parts.length === 4 && what === "mcp" && id) return { kind: "mcp", userId, id };
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function obj(value: string): Record<string, unknown> {
|
|
84
|
+
const parsed: unknown = JSON.parse(value);
|
|
85
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
86
|
+
throw new Error("not an object");
|
|
87
|
+
}
|
|
88
|
+
const o = parsed as Record<string, unknown>;
|
|
89
|
+
if (o.v !== 1) throw new Error(`unsupported version ${String(o.v)}`);
|
|
90
|
+
return o;
|
|
91
|
+
}
|
|
92
|
+
function str(o: Record<string, unknown>, key: string): string {
|
|
93
|
+
const v = o[key];
|
|
94
|
+
if (typeof v !== "string") throw new Error(`bad ${key}`);
|
|
95
|
+
return v;
|
|
96
|
+
}
|
|
97
|
+
function strOrNull(o: Record<string, unknown>, key: string): string | null {
|
|
98
|
+
const v = o[key];
|
|
99
|
+
if (v === null || v === undefined) return null;
|
|
100
|
+
if (typeof v !== "string") throw new Error(`bad ${key}`);
|
|
101
|
+
return v;
|
|
102
|
+
}
|
|
103
|
+
function num(o: Record<string, unknown>, key: string): number {
|
|
104
|
+
const v = o[key];
|
|
105
|
+
if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`bad ${key}`);
|
|
106
|
+
return v;
|
|
107
|
+
}
|
|
108
|
+
function numOrNull(o: Record<string, unknown>, key: string): number | null {
|
|
109
|
+
const v = o[key];
|
|
110
|
+
if (v === null || v === undefined) return null;
|
|
111
|
+
if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`bad ${key}`);
|
|
112
|
+
return v;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function parseStoredGithubToken(value: string): StoredGithubToken {
|
|
116
|
+
const o = obj(value);
|
|
117
|
+
if (o.kind !== "github") throw new Error("not a github token");
|
|
118
|
+
const generation = num(o, "generation");
|
|
119
|
+
if (!Number.isSafeInteger(generation) || generation < 0) throw new Error("bad generation");
|
|
120
|
+
return {
|
|
121
|
+
v: 1,
|
|
122
|
+
kind: "github",
|
|
123
|
+
userId: str(o, "userId"),
|
|
124
|
+
installationId: num(o, "installationId"),
|
|
125
|
+
generation,
|
|
126
|
+
tokenKind: str(o, "tokenKind"),
|
|
127
|
+
token: str(o, "token"),
|
|
128
|
+
expiresAt: numOrNull(o, "expiresAt"),
|
|
129
|
+
updatedAt: num(o, "updatedAt"),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function parseStoredSshKey(value: string): StoredSshKey {
|
|
134
|
+
const o = obj(value);
|
|
135
|
+
if (o.kind !== "ssh") throw new Error("not an ssh key");
|
|
136
|
+
return {
|
|
137
|
+
v: 1,
|
|
138
|
+
kind: "ssh",
|
|
139
|
+
userId: str(o, "userId"),
|
|
140
|
+
publicKey: str(o, "publicKey"),
|
|
141
|
+
privateKey: str(o, "privateKey"),
|
|
142
|
+
createdAt: num(o, "createdAt"),
|
|
143
|
+
updatedAt: num(o, "updatedAt"),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function parseStoredMcpConnection(value: string): StoredMcpConnection {
|
|
148
|
+
const o = obj(value);
|
|
149
|
+
if (o.kind !== "mcp") throw new Error("not an mcp connection");
|
|
150
|
+
return {
|
|
151
|
+
v: 1,
|
|
152
|
+
kind: "mcp",
|
|
153
|
+
id: str(o, "id"),
|
|
154
|
+
userId: str(o, "userId"),
|
|
155
|
+
url: str(o, "url"),
|
|
156
|
+
authKind: str(o, "authKind"),
|
|
157
|
+
tokenEndpoint: strOrNull(o, "tokenEndpoint"),
|
|
158
|
+
redirectUri: strOrNull(o, "redirectUri"),
|
|
159
|
+
clientId: strOrNull(o, "clientId"),
|
|
160
|
+
clientSecret: strOrNull(o, "clientSecret"),
|
|
161
|
+
pkceVerifier: strOrNull(o, "pkceVerifier"),
|
|
162
|
+
secret: strOrNull(o, "secret"),
|
|
163
|
+
status: str(o, "status"),
|
|
164
|
+
scopes: strOrNull(o, "scopes"),
|
|
165
|
+
updatedAt: num(o, "updatedAt"),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function upload(
|
|
170
|
+
store: CredentialStore,
|
|
171
|
+
name: string,
|
|
172
|
+
value: unknown,
|
|
173
|
+
setRef: (ref: string) => void,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
await store.put(name, JSON.stringify(value));
|
|
176
|
+
setRef(name);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function reconcileUserCredentialsFromStore(): Promise<ReconcileUserCredentialsResult> {
|
|
180
|
+
const result: ReconcileUserCredentialsResult = {
|
|
181
|
+
skipped: false,
|
|
182
|
+
imported: 0,
|
|
183
|
+
uploaded: 0,
|
|
184
|
+
removed: 0,
|
|
185
|
+
errors: 0,
|
|
186
|
+
};
|
|
187
|
+
const store = configuredCredentialStore();
|
|
188
|
+
if (!store) return { ...result, skipped: true };
|
|
189
|
+
const prefix = credentialStorePrefix();
|
|
190
|
+
const path = `${prefix}/users`;
|
|
191
|
+
const warn = (line: string): void =>
|
|
192
|
+
console.warn(`[user-credentials] credential store: ${line}`);
|
|
193
|
+
|
|
194
|
+
let listed: Array<{ name: string; value: string }>;
|
|
195
|
+
try {
|
|
196
|
+
listed = await store.list(path);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
result.errors++;
|
|
199
|
+
warn(`listing ${path} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
200
|
+
// Nothing else is safe to decide without the inventory.
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const github = new Map(listGithubTokenStoreRows().map((r) => [r.userId, r.storeRef]));
|
|
205
|
+
const ssh = new Map(listSshKeyStoreRows().map((r) => [r.userId, r.storeRef]));
|
|
206
|
+
const mcp = new Map(listMcpConnectionStoreRows().map((r) => [r.id, r]));
|
|
207
|
+
const seen = new Set<string>();
|
|
208
|
+
|
|
209
|
+
for (const param of listed) {
|
|
210
|
+
const at = parseUserParameterName(param.name, prefix);
|
|
211
|
+
if (!at) continue; // a neighbour from a later slice
|
|
212
|
+
seen.add(param.name);
|
|
213
|
+
try {
|
|
214
|
+
if (at.kind === "github") {
|
|
215
|
+
const value = parseStoredGithubToken(param.value);
|
|
216
|
+
if (value.userId !== at.userId) throw new Error("payload names another user");
|
|
217
|
+
if (github.has(at.userId)) {
|
|
218
|
+
if (github.get(at.userId) !== param.name) setGithubTokenStoreRef(at.userId, param.name);
|
|
219
|
+
} else if (importGithubTokenFromStore(value, param.name)) {
|
|
220
|
+
result.imported++;
|
|
221
|
+
}
|
|
222
|
+
} else if (at.kind === "ssh") {
|
|
223
|
+
const value = parseStoredSshKey(param.value);
|
|
224
|
+
if (value.userId !== at.userId) throw new Error("payload names another user");
|
|
225
|
+
if (ssh.has(at.userId)) {
|
|
226
|
+
if (ssh.get(at.userId) !== param.name) setSshKeyStoreRef(at.userId, param.name);
|
|
227
|
+
} else if (importSshKeyFromStore(value, param.name)) {
|
|
228
|
+
result.imported++;
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
const value = parseStoredMcpConnection(param.value);
|
|
232
|
+
if (value.userId !== at.userId || value.id !== at.id) {
|
|
233
|
+
throw new Error("payload does not describe the connection its path names");
|
|
234
|
+
}
|
|
235
|
+
const local = mcp.get(at.id);
|
|
236
|
+
if (local) {
|
|
237
|
+
if (local.storeRef !== param.name) setMcpConnectionStoreRef(at.id, param.name);
|
|
238
|
+
} else if (importMcpConnectionFromStore(value, param.name)) {
|
|
239
|
+
result.imported++;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} catch (err) {
|
|
243
|
+
result.errors++;
|
|
244
|
+
warn(`${param.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const under = (ref: string): boolean => ref.startsWith(`${path}/`);
|
|
249
|
+
for (const [userId, storeRef] of github) {
|
|
250
|
+
try {
|
|
251
|
+
if (storeRef) {
|
|
252
|
+
if (under(storeRef) && !seen.has(storeRef)) {
|
|
253
|
+
deleteGithubTokenLocal(userId);
|
|
254
|
+
result.removed++;
|
|
255
|
+
}
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const value = githubTokenStoreValue(userId);
|
|
259
|
+
if (!value) continue;
|
|
260
|
+
await upload(store, githubTokenParameterName(userId), value, (ref) =>
|
|
261
|
+
setGithubTokenStoreRef(userId, ref),
|
|
262
|
+
);
|
|
263
|
+
result.uploaded++;
|
|
264
|
+
} catch (err) {
|
|
265
|
+
result.errors++;
|
|
266
|
+
warn(`github token ${userId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
for (const [userId, storeRef] of ssh) {
|
|
270
|
+
try {
|
|
271
|
+
if (storeRef) {
|
|
272
|
+
if (under(storeRef) && !seen.has(storeRef)) {
|
|
273
|
+
deleteSshKeyLocal(userId);
|
|
274
|
+
result.removed++;
|
|
275
|
+
}
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const value = sshKeyStoreValue(userId);
|
|
279
|
+
if (!value) continue;
|
|
280
|
+
await upload(store, sshKeyParameterName(userId), value, (ref) =>
|
|
281
|
+
setSshKeyStoreRef(userId, ref),
|
|
282
|
+
);
|
|
283
|
+
result.uploaded++;
|
|
284
|
+
} catch (err) {
|
|
285
|
+
result.errors++;
|
|
286
|
+
warn(`ssh key ${userId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
for (const [id, row] of mcp) {
|
|
290
|
+
try {
|
|
291
|
+
if (row.storeRef) {
|
|
292
|
+
if (under(row.storeRef) && !seen.has(row.storeRef)) {
|
|
293
|
+
deleteMcpConnectionLocal(id);
|
|
294
|
+
result.removed++;
|
|
295
|
+
}
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const value = mcpConnectionStoreValue(id);
|
|
299
|
+
if (!value) continue;
|
|
300
|
+
await upload(store, mcpConnectionParameterName(row.userId, id), value, (ref) =>
|
|
301
|
+
setMcpConnectionStoreRef(id, ref),
|
|
302
|
+
);
|
|
303
|
+
result.uploaded++;
|
|
304
|
+
} catch (err) {
|
|
305
|
+
result.errors++;
|
|
306
|
+
warn(`mcp connection ${id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return result;
|
|
310
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runuai/host",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.114",
|
|
4
4
|
"description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Uai Tech <team@runuai.com>",
|
|
@@ -76,10 +76,12 @@
|
|
|
76
76
|
"zod": "^3.23.8"
|
|
77
77
|
},
|
|
78
78
|
"devDependencies": {
|
|
79
|
+
"@aws-sdk/client-ssm": "3.899.0",
|
|
79
80
|
"@types/node": "^22.9.0",
|
|
80
81
|
"@types/ws": "^8.18.1",
|
|
81
82
|
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
|
82
83
|
"@typescript-eslint/parser": "^8.59.4",
|
|
84
|
+
"esbuild": "0.25.8",
|
|
83
85
|
"eslint": "^9.14.0",
|
|
84
86
|
"typescript": "^5.6.3",
|
|
85
87
|
"vitest": "^2.1.4"
|
package/scripts/agent/task-up.sh
CHANGED
|
@@ -2828,6 +2828,22 @@ if crt exec -u root "${managed_exec_env[@]}" "$app_container" \
|
|
|
2828
2828
|
/usr/bin/mkdir -p /home/node/.codex >/dev/null; then
|
|
2829
2829
|
for codex_item in auth.json config.toml AGENTS.md version.json installation_id rules; do
|
|
2830
2830
|
if [ -e "$UAI_OWNER_HOME/.codex/$codex_item" ]; then
|
|
2831
|
+
# ADR-135: the task's copy must not carry the rotating refresh token —
|
|
2832
|
+
# N copies racing to refresh it revoke the whole family. Replace it with
|
|
2833
|
+
# the broker placeholder (the field must exist; codex refuses a missing
|
|
2834
|
+
# one). The host is the only refresher and re-pushes on refresh.
|
|
2835
|
+
if [ "$codex_item" = auth.json ] && command -v jq >/dev/null 2>&1; then
|
|
2836
|
+
brokered_auth="$(mktemp "${TMPDIR:-/tmp}/uai-codex-auth.XXXXXX")"
|
|
2837
|
+
if jq 'if (.tokens|type) == "object" and (.tokens.refresh_token|type) == "string" then .tokens.refresh_token = "uai-broker-managed-no-refresh" else . end' \
|
|
2838
|
+
"$UAI_OWNER_HOME/.codex/auth.json" > "$brokered_auth" 2>/dev/null && [ -s "$brokered_auth" ]; then
|
|
2839
|
+
crt cp "$brokered_auth" "$app_container":/home/node/.codex/auth.json >/dev/null \
|
|
2840
|
+
|| log "ERROR: crt cp of brokered .codex/auth.json failed; Codex in this task may not authenticate"
|
|
2841
|
+
rm -f "$brokered_auth"
|
|
2842
|
+
continue
|
|
2843
|
+
fi
|
|
2844
|
+
rm -f "$brokered_auth"
|
|
2845
|
+
log "warning: could not broker .codex/auth.json; copying it verbatim"
|
|
2846
|
+
fi
|
|
2831
2847
|
crt cp "$UAI_OWNER_HOME/.codex/$codex_item" \
|
|
2832
2848
|
"$app_container":/home/node/.codex/ >/dev/null \
|
|
2833
2849
|
|| log "ERROR: crt cp of .codex/$codex_item failed; Codex in this task may not authenticate"
|
|
@@ -2865,11 +2881,25 @@ crt exec -u root "${managed_exec_env[@]}" "$app_container" \
|
|
|
2865
2881
|
|
|
2866
2882
|
# Copy Grok subscription/config into a task-private /home/node/.grok. The Linux
|
|
2867
2883
|
# `grok` binary is baked into the image; here we copy the arch-independent
|
|
2868
|
-
# auth + config
|
|
2884
|
+
# auth + config. auth.json is an OIDC token: the task's copy gets the broker
|
|
2885
|
+
# placeholder in every entry's refresh_token (ADR-135) so it can never rotate
|
|
2886
|
+
# the family; the host is the only refresher.
|
|
2869
2887
|
crt exec -u root "${managed_exec_env[@]}" "$app_container" \
|
|
2870
2888
|
/usr/bin/mkdir -p /home/node/.grok >/dev/null 2>&1 || true
|
|
2871
2889
|
for grok_item in auth.json config.toml models_cache.json agent_id; do
|
|
2872
2890
|
if [ -e "$UAI_OWNER_HOME/.grok/$grok_item" ]; then
|
|
2891
|
+
if [ "$grok_item" = auth.json ] && command -v jq >/dev/null 2>&1; then
|
|
2892
|
+
brokered_auth="$(mktemp "${TMPDIR:-/tmp}/uai-grok-auth.XXXXXX")"
|
|
2893
|
+
if jq 'with_entries(if (.value|type) == "object" and (.value.refresh_token|type) == "string" then .value.refresh_token = "uai-broker-managed-no-refresh" else . end)' \
|
|
2894
|
+
"$UAI_OWNER_HOME/.grok/auth.json" > "$brokered_auth" 2>/dev/null && [ -s "$brokered_auth" ]; then
|
|
2895
|
+
crt cp "$brokered_auth" "$app_container":/home/node/.grok/auth.json >/dev/null 2>&1 \
|
|
2896
|
+
|| log "warning: crt cp of brokered .grok/auth.json failed; grok may need re-login"
|
|
2897
|
+
rm -f "$brokered_auth"
|
|
2898
|
+
continue
|
|
2899
|
+
fi
|
|
2900
|
+
rm -f "$brokered_auth"
|
|
2901
|
+
log "warning: could not broker .grok/auth.json; copying it verbatim"
|
|
2902
|
+
fi
|
|
2873
2903
|
crt cp "$UAI_OWNER_HOME/.grok/$grok_item" \
|
|
2874
2904
|
"$app_container":/home/node/.grok/ >/dev/null 2>&1 \
|
|
2875
2905
|
|| log "warning: crt cp of .grok/$grok_item failed; grok may need re-login"
|
package/src/main.ts
CHANGED
|
@@ -57,7 +57,13 @@ import {
|
|
|
57
57
|
deleteUserSshIdentity,
|
|
58
58
|
removeTaskSshIdentityFromContainer,
|
|
59
59
|
} from "../lib/git-identity";
|
|
60
|
-
import {
|
|
60
|
+
import {
|
|
61
|
+
reinjectCodexRunningTasks,
|
|
62
|
+
reinjectGrokRunningTasks,
|
|
63
|
+
watchCodexAuth,
|
|
64
|
+
} from "../lib/codex-auth";
|
|
65
|
+
import { startCredentialStorePickup } from "../lib/credential-store-pickup";
|
|
66
|
+
import { configureTokenBroker, startTokenBrokerLoop } from "../lib/token-broker-local";
|
|
61
67
|
import {
|
|
62
68
|
ensureKeyForUser as ensureSshKeyForUser,
|
|
63
69
|
getPublicKey as getSshPublicKey,
|
|
@@ -157,6 +163,7 @@ import {
|
|
|
157
163
|
listEngineAccountsAllScopes,
|
|
158
164
|
reconcileEngineAccountsFromStore,
|
|
159
165
|
} from "../lib/engine-accounts";
|
|
166
|
+
import { reconcileUserCredentialsFromStore } from "../lib/user-credentials-store";
|
|
160
167
|
import { createEngineConnectManager } from "../lib/engine-connect";
|
|
161
168
|
import { createEngineLoginManager } from "../lib/engine-login";
|
|
162
169
|
import {
|
|
@@ -458,6 +465,29 @@ void reconcileEngineAccountsFromStore({ hostId })
|
|
|
458
465
|
`[engine-accounts] credential store reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
459
466
|
);
|
|
460
467
|
});
|
|
468
|
+
// ADR-135: one refresher per rotating credential. Owned host → the in-process
|
|
469
|
+
// loop refreshes and re-pushes; Uai-operated host → the broker Lambda
|
|
470
|
+
// refreshes and this host picks up changed parameter versions.
|
|
471
|
+
const repushBrokeredCopies = async (kind: string): Promise<void> => {
|
|
472
|
+
if (kind === "codex") await reinjectCodexRunningTasks();
|
|
473
|
+
if (kind === "grok") await reinjectGrokRunningTasks();
|
|
474
|
+
await getOrchestrator().refreshEngineAccountCopies(kind);
|
|
475
|
+
};
|
|
476
|
+
configureTokenBroker({ repush: repushBrokeredCopies });
|
|
477
|
+
startTokenBrokerLoop();
|
|
478
|
+
startCredentialStorePickup({ repush: repushBrokeredCopies });
|
|
479
|
+
void reconcileUserCredentialsFromStore()
|
|
480
|
+
.then((r) => {
|
|
481
|
+
if (r.skipped) return;
|
|
482
|
+
console.log(
|
|
483
|
+
`[user-credentials] credential store reconciled: ${r.imported} imported, ${r.uploaded} uploaded, ${r.removed} removed, ${r.errors} errors`,
|
|
484
|
+
);
|
|
485
|
+
})
|
|
486
|
+
.catch((error: unknown) => {
|
|
487
|
+
console.warn(
|
|
488
|
+
`[user-credentials] credential store reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
489
|
+
);
|
|
490
|
+
});
|
|
461
491
|
// Reconcile a durable retention-off setting before recovery/connect can make
|
|
462
492
|
// crash-left owner-facing terminal history observable again. Correctness
|
|
463
493
|
// tombstones remain intact inside applyTerminalHistoryRetention.
|