@gjermundgaraba/clankercreds 0.1.1 → 0.2.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/dist/main.mjs +85 -96
- package/package.json +6 -6
package/dist/main.mjs
CHANGED
|
@@ -3,12 +3,11 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
|
-
import { Option, Predicate, Schema } from "effect";
|
|
7
|
-
import { HttpClient, HttpClientRequest } from "effect/
|
|
8
|
-
import * as ActionHttpClient from "@gjermundgaraba/effect-actions/ActionHttpClient";
|
|
6
|
+
import { Context, Data, Effect, Option, Predicate, Schema } from "effect";
|
|
7
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
9
8
|
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
10
|
-
import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
|
|
11
9
|
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
10
|
+
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
12
11
|
import { YAMLMap, isMap, parseDocument } from "yaml";
|
|
13
12
|
//#region src/files.ts
|
|
14
13
|
/** Secrets are readable by their owner only, in directories only the owner can enter. */
|
|
@@ -98,44 +97,49 @@ const loadConfig = () => {
|
|
|
98
97
|
configHome: configHome(home)
|
|
99
98
|
};
|
|
100
99
|
};
|
|
101
|
-
|
|
102
|
-
//#region ../../node_modules/.pnpm/@gjermundgaraba+clankerauth-sdk@0.9.0_@gjermundgaraba+effect-actions@0.7.0_effect@4.0.0-rc.117_/node_modules/@gjermundgaraba/clankerauth-sdk/dist/errors.js
|
|
100
|
+
Data.TaggedError("Unauthorized");
|
|
103
101
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* Schemas define public responses. Diagnostic causes are internal, non-enumerable fields.
|
|
102
|
+
* The issuer could not be reached, so the credential could not be decided: a 503, not a
|
|
103
|
+
* refusal. A resource's descriptor declares it,
|
|
104
|
+
* `Authentication.make("notes.Login", CurrentPrincipal, { error: ProviderUnavailable })`.
|
|
105
|
+
* Its public response is the schema. Diagnostic causes are internal, non-enumerable fields.
|
|
109
106
|
*/
|
|
110
|
-
var Unauthorized = class extends Schema.TaggedError()("Unauthorized", { message: Schema.String }, { httpApiStatus: 401 }) {
|
|
111
|
-
constructor(options) {
|
|
112
|
-
super({ message: options.message });
|
|
113
|
-
Object.defineProperty(this, "cause", { value: options.cause });
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
var RateLimited = class extends Schema.TaggedError()("RateLimited", { message: Schema.String }, { httpApiStatus: 429 }) {};
|
|
117
107
|
var ProviderUnavailable = class extends Schema.TaggedError()("ProviderUnavailable", { operation: Schema.String }, { httpApiStatus: 503 }) {
|
|
118
108
|
constructor(options) {
|
|
119
109
|
super({ operation: options.operation });
|
|
120
110
|
Object.defineProperty(this, "cause", { value: options.cause });
|
|
121
111
|
}
|
|
122
112
|
};
|
|
113
|
+
Data.TaggedError("InsufficientScope");
|
|
114
|
+
Schema.TaggedError()("ConfigurationError", { message: Schema.String });
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region ../../node_modules/.pnpm/@gjermundgaraba+clankerauth-sdk@0.13.0_@gjermundgaraba+effect-actions@0.10.0_effect@4.0.0__effect@4.0.0/node_modules/@gjermundgaraba/clankerauth-sdk/dist/session.js
|
|
123
117
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
118
|
+
* The identity a browser page needs, without any verification code. This entry point is
|
|
119
|
+
* browser-safe: it declares the identity a protected contract states, the `whoami` contract
|
|
120
|
+
* and the issuer's sign-out URL, so contracts, bindings and pages import it directly, and a
|
|
121
|
+
* server answers `Whoami` with its resource's `session`.
|
|
126
122
|
*/
|
|
127
|
-
var InsufficientScope = class extends Schema.TaggedError()("InsufficientScope", { scope: Schema.String }, { httpApiStatus: 403 }) {};
|
|
128
|
-
Schema.TaggedError()("ConfigurationError", { message: Schema.String });
|
|
129
123
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
124
|
+
* The caller: what a protected contract states, `caller: CurrentPrincipal`, and what an
|
|
125
|
+
* authentication descriptor proves,
|
|
126
|
+
* `Authentication.make("notes.Login", CurrentPrincipal, { error: ProviderUnavailable })`.
|
|
127
|
+
* Remotely, the resource's provider gives it per request, from the verified credential; on
|
|
128
|
+
* a trusted local surface, the host gives it the process, `Notes.local(subject)`.
|
|
132
129
|
*/
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
130
|
+
var CurrentPrincipal = class extends Context.Service()("@clankerauth/CurrentPrincipal") {};
|
|
131
|
+
/** The verified credential behind a request, as the resource server read it. */
|
|
132
|
+
const Principal = Schema.Struct({
|
|
133
|
+
subject: Schema.NonEmptyString,
|
|
134
|
+
issuer: Schema.NonEmptyString,
|
|
135
|
+
scopes: Schema.Array(Schema.String)
|
|
136
|
+
});
|
|
137
|
+
Action.make("whoami", {
|
|
138
|
+
description: "The verified subject, issuer and scopes behind this request's credential.",
|
|
139
|
+
readOnly: true,
|
|
140
|
+
caller: CurrentPrincipal,
|
|
141
|
+
success: Principal
|
|
142
|
+
});
|
|
139
143
|
//#endregion
|
|
140
144
|
//#region ../../packages/api/src/bundle.ts
|
|
141
145
|
const GhCredential = Schema.Struct({
|
|
@@ -169,7 +173,11 @@ const PiCredential = Schema.Struct({
|
|
|
169
173
|
accessToken: Schema.String
|
|
170
174
|
});
|
|
171
175
|
/**
|
|
172
|
-
* The
|
|
176
|
+
* The service sends typed credentials, never file contents or paths, so it cannot write
|
|
177
|
+
* anything on a machine but what the client knows how to place. The cost is that a new
|
|
178
|
+
* tool, or a placement fix, needs a client release, which new machines install.
|
|
179
|
+
*
|
|
180
|
+
* Each machine keeps the client its setup installed, so old clients read what the service sends. A change
|
|
173
181
|
* an old client would get wrong is a new type name, which it skips: the decoder drops fields
|
|
174
182
|
* it does not know, so one ignoring `host` would place a GHE token as github.com's. A field
|
|
175
183
|
* an old client can safely ignore may be added, as pi's `accessToken` was; the service ships
|
|
@@ -194,8 +202,6 @@ const UnknownCredential = Schema.Struct({ type: Schema.String.check(Schema.makeF
|
|
|
194
202
|
const Bundle = Schema.Struct({ credentials: Schema.Array(Schema.Union([TypedCredential, UnknownCredential])) });
|
|
195
203
|
//#endregion
|
|
196
204
|
//#region ../../packages/api/src/shared.ts
|
|
197
|
-
/** The caller asked for something the service cannot do with the input given. */
|
|
198
|
-
var BadRequest = class extends Schema.TaggedError()("BadRequest", { message: Schema.String }, { httpApiStatus: 400 }) {};
|
|
199
205
|
/**
|
|
200
206
|
* What was asked for does not exist: a bundle, credential or sign-in by that name, a
|
|
201
207
|
* bundle for the calling key, or a ChatGPT login in the bundle that has none.
|
|
@@ -206,19 +212,6 @@ Schema.TaggedError()("Conflict", { message: Schema.String }, { httpApiStatus: 40
|
|
|
206
212
|
var NeedsSignIn = class extends Schema.TaggedError()("NeedsSignIn", { message: Schema.String }, { httpApiStatus: 409 }) {};
|
|
207
213
|
/** OpenAI could not be reached and no valid token is held. Waiting helps; retrying now does not. */
|
|
208
214
|
var UpstreamUnavailable = class extends Schema.TaggedError()("UpstreamUnavailable", { message: Schema.String }, { httpApiStatus: 503 }) {};
|
|
209
|
-
/** The request could not be represented on the wire; never a caller-fixable failure. */
|
|
210
|
-
var InternalError = class extends Schema.TaggedError()("InternalError", { message: Schema.String }, { httpApiStatus: 500 }) {};
|
|
211
|
-
/** Transport failures answer with the same public errors handlers use. */
|
|
212
|
-
const schemaError = {
|
|
213
|
-
invalid: {
|
|
214
|
-
schema: BadRequest,
|
|
215
|
-
make: () => new BadRequest({ message: "The request does not match the action's input." })
|
|
216
|
-
},
|
|
217
|
-
internal: {
|
|
218
|
-
schema: InternalError,
|
|
219
|
-
make: () => new InternalError({ message: "The response could not be encoded." })
|
|
220
|
-
}
|
|
221
|
-
};
|
|
222
215
|
Schema.String.check(Schema.isPattern(/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/));
|
|
223
216
|
Schema.String.check(Schema.isPattern(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/), Schema.isMaxLength(253));
|
|
224
217
|
/**
|
|
@@ -257,28 +250,25 @@ const claimsOf = (jwt) => {
|
|
|
257
250
|
* What a machine key can do: read the one bundle bound to it, and nothing else. The
|
|
258
251
|
* bundle is never named: the key is bound to it on the server.
|
|
259
252
|
*/
|
|
260
|
-
const Machine =
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}, Action.make("bundle", {
|
|
264
|
-
access: "read",
|
|
253
|
+
const Machine = [Action.make("bundle", {
|
|
254
|
+
readOnly: false,
|
|
255
|
+
caller: CurrentPrincipal,
|
|
265
256
|
description: "Fetch the typed credentials of the bundle bound to the calling key.",
|
|
266
257
|
input: Schema.Struct({ clientId: ClientId }),
|
|
267
258
|
success: Bundle,
|
|
268
|
-
|
|
269
|
-
mcp: false
|
|
259
|
+
error: NotFound
|
|
270
260
|
}), Action.make("codexLogin", {
|
|
271
|
-
|
|
261
|
+
readOnly: false,
|
|
262
|
+
caller: CurrentPrincipal,
|
|
272
263
|
description: "The current ChatGPT login of the bundle bound to the calling key, as the codex type carries it, refreshed first when it has expired.",
|
|
273
264
|
input: Schema.Struct({ clientId: ClientId }),
|
|
274
265
|
success: CodexLogin,
|
|
275
|
-
|
|
266
|
+
error: [
|
|
276
267
|
NotFound,
|
|
277
268
|
NeedsSignIn,
|
|
278
269
|
UpstreamUnavailable
|
|
279
|
-
]
|
|
280
|
-
|
|
281
|
-
}));
|
|
270
|
+
]
|
|
271
|
+
})];
|
|
282
272
|
/**
|
|
283
273
|
* Codex treats a 401 as permanent and stops retrying; every other failure is transient.
|
|
284
274
|
* The codes are the ones Codex classifies: `refresh_token_invalidated` reads as revoked.
|
|
@@ -293,26 +283,27 @@ var CodexRefreshUnavailable = class extends Schema.TaggedError()("CodexRefreshUn
|
|
|
293
283
|
}) }, { httpApiStatus: 503 }) {};
|
|
294
284
|
/**
|
|
295
285
|
* Codex's own refresh request, the one request whose shape is not ours. The refresh-token
|
|
296
|
-
* field carries `<clientId>:<key>`, so
|
|
297
|
-
*
|
|
298
|
-
* composite it has.
|
|
286
|
+
* field carries `<clientId>:<key>`, so the contract is public and its handler authenticates
|
|
287
|
+
* the key against the machine resource. The answer leaves `refresh_token` out, so Codex keeps the
|
|
288
|
+
* composite it has. Its input alone accepts fields it does not name: Codex may add one,
|
|
289
|
+
* and every other action refuses them.
|
|
299
290
|
*/
|
|
300
|
-
const
|
|
301
|
-
|
|
291
|
+
const CodexRefresh = Action.make("codexRefresh", {
|
|
292
|
+
readOnly: false,
|
|
293
|
+
caller: Action.Anyone,
|
|
302
294
|
description: "Answer Codex's token refresh with the bundle's current ChatGPT tokens.",
|
|
303
|
-
input: Schema.Struct({
|
|
295
|
+
input: Schema.StructWithRest(Schema.Struct({
|
|
304
296
|
grant_type: Schema.Literal("refresh_token"),
|
|
305
297
|
refresh_token: Schema.String,
|
|
306
298
|
client_id: Schema.optionalKey(Schema.String),
|
|
307
299
|
scope: Schema.optionalKey(Schema.String)
|
|
308
|
-
}),
|
|
300
|
+
}), [Schema.Record(Schema.String, Schema.Json)]),
|
|
309
301
|
success: Schema.Struct({
|
|
310
302
|
access_token: Schema.String,
|
|
311
303
|
id_token: Schema.String
|
|
312
304
|
}),
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
}));
|
|
305
|
+
error: [CodexRefreshRejected, CodexRefreshUnavailable]
|
|
306
|
+
});
|
|
316
307
|
/** `<clientId>:<key>`. Client ids exclude `:`, so the key is the rest. */
|
|
317
308
|
const formatCodexRefreshToken = (clientId, key) => `${clientId}:${key}`;
|
|
318
309
|
const parseCodexRefreshToken = (value) => {
|
|
@@ -322,47 +313,45 @@ const parseCodexRefreshToken = (value) => {
|
|
|
322
313
|
key: value.slice(colon + 1)
|
|
323
314
|
};
|
|
324
315
|
};
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
316
|
+
/**
|
|
317
|
+
* A machine key's bearer token on the public port, verified by the machine resource's own
|
|
318
|
+
* provider, which requires `clankercreds:machine`: a key without that scope is refused here.
|
|
319
|
+
*/
|
|
320
|
+
const MachineLogin = Authentication.make("clankercreds.machine", CurrentPrincipal, { error: ProviderUnavailable });
|
|
321
|
+
/** Everything the public listener serves: the machine actions, and Codex's refresh beside them. */
|
|
322
|
+
const MachineHttp = ActionHttp.make([...Machine, CodexRefresh], {
|
|
323
|
+
prefix: "/v1",
|
|
324
|
+
authentication: MachineLogin
|
|
325
|
+
});
|
|
331
326
|
//#endregion
|
|
332
327
|
//#region src/service.ts
|
|
333
328
|
/** The service answered and said no. Retrying the same request will not help. */
|
|
334
329
|
var Refused = class extends Error {};
|
|
335
330
|
/** No answer worth acting on. Whatever the machine already holds stays in place. */
|
|
336
331
|
var Unreachable = class extends Error {};
|
|
337
|
-
|
|
338
|
-
* The deadline covers the whole request, the body included. It matters most to `token`:
|
|
339
|
-
* pi gives the command about ten seconds, and a hang would cost it a token it already holds.
|
|
340
|
-
*/
|
|
341
|
-
const connect = (config, deadlineMs) => ActionHttpClient.promise(MachineHttp, {
|
|
332
|
+
const connect = (config) => ActionHttp.fetchClient(MachineHttp, {
|
|
342
333
|
baseUrl: config.service,
|
|
343
|
-
transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(config.key))
|
|
344
|
-
fetch: (input, init) => {
|
|
345
|
-
const deadline = AbortSignal.timeout(deadlineMs);
|
|
346
|
-
const signal = init?.signal ? AbortSignal.any([init.signal, deadline]) : deadline;
|
|
347
|
-
return fetch(input, {
|
|
348
|
-
...init,
|
|
349
|
-
signal
|
|
350
|
-
});
|
|
351
|
-
}
|
|
334
|
+
transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(config.key))
|
|
352
335
|
});
|
|
353
336
|
/**
|
|
354
|
-
* The key's own refusals
|
|
355
|
-
* outage: transport, upstream, a broken answer, or a
|
|
337
|
+
* The key's own refusals, input the service will not take, and the declared answers are
|
|
338
|
+
* refusals. Whatever is left is an outage: transport, upstream, a broken answer, or a
|
|
339
|
+
* service that never answers within its deadline.
|
|
356
340
|
*/
|
|
357
341
|
const classify = (error) => {
|
|
358
|
-
if (error instanceof
|
|
359
|
-
if (error instanceof
|
|
360
|
-
if (error instanceof
|
|
342
|
+
if (error instanceof Action.Unauthenticated) return new Refused("The machine key was refused. It may have been revoked or expired, or not be granted here.");
|
|
343
|
+
if (error instanceof Action.Forbidden) return new Refused("The machine key does not carry the machine scope. A new grant takes up to a minute to apply.");
|
|
344
|
+
if (error instanceof Action.InvalidInput || error instanceof NotFound || error instanceof NeedsSignIn) return new Refused(error.message);
|
|
361
345
|
return new Unreachable(error instanceof Error ? error.message : String(error));
|
|
362
346
|
};
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
347
|
+
/**
|
|
348
|
+
* One call, within its deadline. The deadline covers the whole request, the body included:
|
|
349
|
+
* the call is interrupted, which aborts its `fetch`. It matters most to `token`: pi gives
|
|
350
|
+
* the command about ten seconds, and a hang would cost it a token it already holds.
|
|
351
|
+
*/
|
|
352
|
+
const within = (call, deadlineMs) => Effect.runPromise(Effect.timeout(call, deadlineMs)).catch((error) => Promise.reject(classify(error)));
|
|
353
|
+
const fetchBundle = (config, clientId, deadlineMs = 3e4) => within(connect(config).bundle({ clientId }), deadlineMs);
|
|
354
|
+
const fetchCodexLogin = (config, clientId, deadlineMs = 5e3) => within(connect(config).codexLogin({ clientId }), deadlineMs);
|
|
366
355
|
//#endregion
|
|
367
356
|
//#region src/place/claude.ts
|
|
368
357
|
const settingsFile = (home) => join(home, ".claude/settings.json");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjermundgaraba/clankercreds",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The machine client of clankercreds: fetches a bundle of credentials and places each where its tool expects it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -19,17 +19,17 @@
|
|
|
19
19
|
"access": "public"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@gjermundgaraba/effect-actions": "0.
|
|
23
|
-
"effect": "4.0.0
|
|
22
|
+
"@gjermundgaraba/effect-actions": "0.10.0",
|
|
23
|
+
"effect": "4.0.0",
|
|
24
24
|
"yaml": "2.9.1"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@clankercreds/api": "0.0.0",
|
|
28
|
-
"@gjermundgaraba/clankerauth-sdk": "0.
|
|
28
|
+
"@gjermundgaraba/clankerauth-sdk": "0.13.0",
|
|
29
29
|
"@types/node": "26.6.2",
|
|
30
30
|
"typescript": "7.0.2",
|
|
31
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@1.0.0
|
|
32
|
-
"vite-plus": "1.0.0
|
|
31
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.0.0",
|
|
32
|
+
"vite-plus": "1.0.0"
|
|
33
33
|
},
|
|
34
34
|
"engines": {
|
|
35
35
|
"node": ">=26"
|