@naswerks/periscope 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/SECURITY.md +315 -0
- package/dist/bin/command.d.ts +97 -0
- package/dist/bin/command.js +141 -0
- package/dist/bin/config.d.ts +7 -0
- package/dist/bin/config.js +90 -0
- package/dist/bin/login.d.ts +48 -0
- package/dist/bin/login.js +82 -0
- package/dist/bin/main.d.ts +33 -0
- package/dist/bin/main.js +173 -0
- package/dist/bin/pair.d.ts +28 -0
- package/dist/bin/pair.js +198 -0
- package/dist/bin/periscope.d.ts +3 -0
- package/dist/bin/periscope.js +4 -0
- package/dist/bin/posture.d.ts +64 -0
- package/dist/bin/posture.js +157 -0
- package/dist/bin/reconfigure.d.ts +64 -0
- package/dist/bin/reconfigure.js +195 -0
- package/dist/bin/serve.d.ts +103 -0
- package/dist/bin/serve.js +441 -0
- package/dist/bin/status.d.ts +11 -0
- package/dist/bin/status.js +49 -0
- package/dist/bin/workspaces.d.ts +109 -0
- package/dist/bin/workspaces.js +215 -0
- package/dist/control/backoff.d.ts +25 -0
- package/dist/control/backoff.js +20 -0
- package/dist/control/codec.d.ts +15 -0
- package/dist/control/codec.js +501 -0
- package/dist/control/credential.d.ts +23 -0
- package/dist/control/credential.js +8 -0
- package/dist/control/frames.d.ts +1180 -0
- package/dist/control/frames.js +441 -0
- package/dist/control/link-state.d.ts +32 -0
- package/dist/control/link-state.js +39 -0
- package/dist/control/link.d.ts +109 -0
- package/dist/control/link.js +736 -0
- package/dist/control/queue.d.ts +110 -0
- package/dist/control/queue.js +174 -0
- package/dist/control/seq.d.ts +49 -0
- package/dist/control/seq.js +52 -0
- package/dist/control/stream-routing.d.ts +200 -0
- package/dist/control/stream-routing.js +167 -0
- package/dist/control/stream.d.ts +43 -0
- package/dist/control/stream.js +64 -0
- package/dist/core/async-queue.d.ts +26 -0
- package/dist/core/async-queue.js +63 -0
- package/dist/core/failure.d.ts +22 -0
- package/dist/core/failure.js +53 -0
- package/dist/core/index.d.ts +18 -0
- package/dist/core/index.js +7 -0
- package/dist/core/keyed-turns.d.ts +28 -0
- package/dist/core/keyed-turns.js +44 -0
- package/dist/core/paths.d.ts +25 -0
- package/dist/core/paths.js +77 -0
- package/dist/core/refusal.d.ts +43 -0
- package/dist/core/refusal.js +399 -0
- package/dist/core/result.d.ts +22 -0
- package/dist/core/result.js +16 -0
- package/dist/core/time.d.ts +19 -0
- package/dist/core/time.js +25 -0
- package/dist/core/vocab.d.ts +23 -0
- package/dist/core/vocab.js +37 -0
- package/dist/core/workspace-id.d.ts +12 -0
- package/dist/core/workspace-id.js +12 -0
- package/dist/gate/command.d.ts +136 -0
- package/dist/gate/command.js +551 -0
- package/dist/gate/decision.d.ts +111 -0
- package/dist/gate/decision.js +97 -0
- package/dist/gate/escalate.d.ts +82 -0
- package/dist/gate/escalate.js +92 -0
- package/dist/gate/gate.d.ts +173 -0
- package/dist/gate/gate.js +417 -0
- package/dist/gate/index.d.ts +39 -0
- package/dist/gate/index.js +31 -0
- package/dist/gate/jail.d.ts +92 -0
- package/dist/gate/jail.js +131 -0
- package/dist/gate/local.d.ts +69 -0
- package/dist/gate/local.js +51 -0
- package/dist/gate/outcome.d.ts +72 -0
- package/dist/gate/outcome.js +91 -0
- package/dist/gate/shell.d.ts +36 -0
- package/dist/gate/shell.js +226 -0
- package/dist/host/agent-process.d.ts +452 -0
- package/dist/host/agent-process.js +382 -0
- package/dist/host/bulk-post.d.ts +44 -0
- package/dist/host/bulk-post.js +114 -0
- package/dist/host/claude-transcripts.d.ts +84 -0
- package/dist/host/claude-transcripts.js +339 -0
- package/dist/host/config-file.d.ts +60 -0
- package/dist/host/config-file.js +216 -0
- package/dist/host/git-facts.d.ts +7 -0
- package/dist/host/git-facts.js +106 -0
- package/dist/host/hooks.d.ts +56 -0
- package/dist/host/hooks.js +75 -0
- package/dist/host/host.d.ts +356 -0
- package/dist/host/host.js +1294 -0
- package/dist/host/index.d.ts +35 -0
- package/dist/host/index.js +22 -0
- package/dist/host/link-state-file.d.ts +18 -0
- package/dist/host/link-state-file.js +66 -0
- package/dist/host/loopback.d.ts +38 -0
- package/dist/host/loopback.js +122 -0
- package/dist/host/machine.d.ts +9 -0
- package/dist/host/machine.js +19 -0
- package/dist/host/mcp-server.d.ts +11 -0
- package/dist/host/mcp-server.js +48 -0
- package/dist/host/package-facts.d.ts +3 -0
- package/dist/host/package-facts.js +26 -0
- package/dist/host/paired-credential-store.d.ts +32 -0
- package/dist/host/paired-credential-store.js +112 -0
- package/dist/host/paths.d.ts +80 -0
- package/dist/host/paths.js +165 -0
- package/dist/host/repository-read.d.ts +33 -0
- package/dist/host/repository-read.js +201 -0
- package/dist/host/session-store.d.ts +53 -0
- package/dist/host/session-store.js +100 -0
- package/dist/host/sign-in.d.ts +90 -0
- package/dist/host/sign-in.js +239 -0
- package/dist/host/telemetry.d.ts +42 -0
- package/dist/host/telemetry.js +74 -0
- package/dist/host/token-cache.d.ts +62 -0
- package/dist/host/token-cache.js +185 -0
- package/dist/host/transcript-fs.d.ts +4 -0
- package/dist/host/transcript-fs.js +104 -0
- package/dist/host/wire-request.d.ts +80 -0
- package/dist/host/wire-request.js +196 -0
- package/dist/host/workspace-fs.d.ts +14 -0
- package/dist/host/workspace-fs.js +84 -0
- package/dist/host/workspace-trust.d.ts +23 -0
- package/dist/host/workspace-trust.js +80 -0
- package/dist/identity/authorize.d.ts +59 -0
- package/dist/identity/authorize.js +72 -0
- package/dist/identity/config.d.ts +98 -0
- package/dist/identity/config.js +130 -0
- package/dist/identity/credential.d.ts +91 -0
- package/dist/identity/credential.js +76 -0
- package/dist/identity/device-code.d.ts +115 -0
- package/dist/identity/device-code.js +134 -0
- package/dist/identity/index.d.ts +33 -0
- package/dist/identity/index.js +10 -0
- package/dist/identity/mode.d.ts +106 -0
- package/dist/identity/mode.js +69 -0
- package/dist/identity/paired-credential.d.ts +52 -0
- package/dist/identity/paired-credential.js +43 -0
- package/dist/identity/pkce.d.ts +50 -0
- package/dist/identity/pkce.js +94 -0
- package/dist/identity/store.d.ts +43 -0
- package/dist/identity/store.js +43 -0
- package/dist/identity/token.d.ts +58 -0
- package/dist/identity/token.js +149 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +53 -0
- package/dist/mcp/descriptor.d.ts +59 -0
- package/dist/mcp/descriptor.js +14 -0
- package/dist/mcp/index.d.ts +14 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/schema.d.ts +37 -0
- package/dist/mcp/schema.js +175 -0
- package/dist/mcp/server.d.ts +107 -0
- package/dist/mcp/server.js +66 -0
- package/dist/persistence/entry.d.ts +74 -0
- package/dist/persistence/entry.js +105 -0
- package/dist/persistence/index.d.ts +22 -0
- package/dist/persistence/index.js +8 -0
- package/dist/persistence/key.d.ts +46 -0
- package/dist/persistence/key.js +33 -0
- package/dist/persistence/mirror.d.ts +71 -0
- package/dist/persistence/mirror.js +57 -0
- package/dist/persistence/receipt.d.ts +111 -0
- package/dist/persistence/receipt.js +85 -0
- package/dist/persistence/retention.d.ts +68 -0
- package/dist/persistence/retention.js +68 -0
- package/dist/persistence/store.d.ts +106 -0
- package/dist/persistence/store.js +86 -0
- package/dist/persistence/transition-log.d.ts +73 -0
- package/dist/persistence/transition-log.js +133 -0
- package/dist/protocol.d.ts +27 -0
- package/dist/protocol.js +12 -0
- package/dist/sessions/index.d.ts +8 -0
- package/dist/sessions/index.js +4 -0
- package/dist/sessions/registry.d.ts +186 -0
- package/dist/sessions/registry.js +190 -0
- package/dist/sessions/session.d.ts +178 -0
- package/dist/sessions/session.js +288 -0
- package/dist/sessions/spawn-env.d.ts +88 -0
- package/dist/sessions/spawn-env.js +277 -0
- package/dist/state/coverage.d.ts +308 -0
- package/dist/state/coverage.js +315 -0
- package/dist/state/index.d.ts +12 -0
- package/dist/state/index.js +7 -0
- package/dist/state/machine.d.ts +112 -0
- package/dist/state/machine.js +237 -0
- package/dist/state/model.d.ts +233 -0
- package/dist/state/model.js +214 -0
- package/dist/state/observer.d.ts +55 -0
- package/dist/state/observer.js +413 -0
- package/dist/state/reporter.d.ts +45 -0
- package/dist/state/reporter.js +35 -0
- package/dist/state/store.d.ts +56 -0
- package/dist/state/store.js +120 -0
- package/dist/telemetry/index.d.ts +11 -0
- package/dist/telemetry/index.js +2 -0
- package/dist/telemetry/usage.d.ts +78 -0
- package/dist/telemetry/usage.js +69 -0
- package/dist/workspace/git-worktree.d.ts +150 -0
- package/dist/workspace/git-worktree.js +417 -0
- package/dist/workspace/index.d.ts +9 -0
- package/dist/workspace/index.js +4 -0
- package/dist/workspace/plain-dir.d.ts +34 -0
- package/dist/workspace/plain-dir.js +90 -0
- package/dist/workspace/provider.d.ts +152 -0
- package/dist/workspace/provider.js +2 -0
- package/dist/workspace/worktree-porcelain.d.ts +29 -0
- package/dist/workspace/worktree-porcelain.js +100 -0
- package/docs/architecture.md +277 -0
- package/docs/configuration.md +187 -0
- package/docs/gate.md +219 -0
- package/docs/identity.md +107 -0
- package/docs/protocol.md +501 -0
- package/docs/state-machine.md +160 -0
- package/examples/README.md +141 -0
- package/examples/minimal-controller/controller.ts +38 -0
- package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
- package/examples/parallel-run-proof/run.ts +850 -0
- package/examples/test-controller/controller.ts +655 -0
- package/examples/test-controller/serve.ts +29 -0
- package/examples/tsconfig.json +30 -0
- package/package.json +98 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The assembly: the one place the identity parts become a working sign-in.
|
|
3
|
+
*
|
|
4
|
+
* Why this file exists at all: a set of individually correct parts with nothing composing them is a
|
|
5
|
+
* failure mode (a gate, an observer and a session each proven, with nothing wiring them together,
|
|
6
|
+
* so a default session runs ungated). Identity has exactly the same shape: a config reader, a PKCE
|
|
7
|
+
* generator, a listener, a token parser and a cache are five green modules and zero working
|
|
8
|
+
* sign-ins. This is the sixth thing, and without it the other five are a claim rather than a
|
|
9
|
+
* capability.
|
|
10
|
+
*
|
|
11
|
+
* The transport is injected. `fetch` and the browser-opener are parameters, so the whole flow
|
|
12
|
+
* (discovery, redemption, refresh, and every failure in them) is exercised against a real local HTTP
|
|
13
|
+
* server in the tests rather than against a mock of one. What is not exercised is a real identity
|
|
14
|
+
* provider; that needs an app registration, which is a human act and deliberately not this code's.
|
|
15
|
+
*/
|
|
16
|
+
import type { Result } from '../core/result.js';
|
|
17
|
+
import type { Ticker } from '../core/time.js';
|
|
18
|
+
import type { IdentityConfig, IdentityEndpoints } from '../identity/config.js';
|
|
19
|
+
import type { AuthProtocol } from '../identity/device-code.js';
|
|
20
|
+
import type { CachedTokens, TokenStore } from '../identity/store.js';
|
|
21
|
+
import type { TokenRefresher } from '../identity/credential.js';
|
|
22
|
+
/** The injectable edges. Every one has a real default; the tests replace the transport. */
|
|
23
|
+
export interface SignInDeps {
|
|
24
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
25
|
+
/** Told where to send the user. Defaults to printing the URL, which works on a headless box. */
|
|
26
|
+
readonly present?: (url: string) => void;
|
|
27
|
+
readonly timeoutMs?: number;
|
|
28
|
+
readonly nowMs?: Ticker;
|
|
29
|
+
/** Injected so a device-code poll loop is testable without real waiting. */
|
|
30
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The endpoints, from configuration or from the provider's discovery document.
|
|
34
|
+
*
|
|
35
|
+
* Stated endpoints win and skip the network entirely — an operator who pinned them gets no
|
|
36
|
+
* surprise round trip, and an air-gapped or proxied deployment can work without discovery at all.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveEndpoints(config: IdentityConfig, fetchImpl?: typeof globalThis.fetch): Promise<Result<IdentityEndpoints>>;
|
|
39
|
+
/**
|
|
40
|
+
* Sign a user in through the loopback redirect, and persist what comes back.
|
|
41
|
+
*
|
|
42
|
+
* The listener is opened before the authorization URL is built, because the redirect URI has to
|
|
43
|
+
* carry the port the OS actually gave. Building the URL first would mean either guessing a port
|
|
44
|
+
* or fixing one, and a fixed port collides with whatever else is running on a developer's machine.
|
|
45
|
+
*
|
|
46
|
+
* And the listener is closed on every path. It is a socket accepting callbacks; leaving it open
|
|
47
|
+
* after a failed exchange would leave the thing this flow exists to bound still bound.
|
|
48
|
+
*/
|
|
49
|
+
export declare function signIn(config: IdentityConfig, store: TokenStore, deps?: SignInDeps): Promise<Result<CachedTokens>>;
|
|
50
|
+
/** What a device-code sign-in tells the operator to do, before it starts waiting. */
|
|
51
|
+
export interface DeviceCodeInstruction {
|
|
52
|
+
readonly verificationUri: string;
|
|
53
|
+
readonly userCode: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Sign in through the device-code flow, for a box with no browser on it.
|
|
57
|
+
*
|
|
58
|
+
* It refuses unless it was explicitly enabled, and that check is the first thing here. The
|
|
59
|
+
* provider calls this a high-risk method and recommends blocking it; a tenant refusing it outright
|
|
60
|
+
* is an expected outcome. It must be asked for by name, never arrived at by falling back — see
|
|
61
|
+
* `identity/device-code.ts` for the policy-blocked-flow trap it also carries.
|
|
62
|
+
*
|
|
63
|
+
* The poll interval is the provider's, not this package's, and `slow_down` widens it. Polling faster than
|
|
64
|
+
* asked is how a client gets rate-limited into failing a sign-in that would have worked.
|
|
65
|
+
*/
|
|
66
|
+
export declare function signInWithDeviceCode(config: IdentityConfig, store: TokenStore, instruct: (instruction: DeviceCodeInstruction) => void, deps?: SignInDeps): Promise<Result<CachedTokens>>;
|
|
67
|
+
/**
|
|
68
|
+
* The refresher `TokenCredential` calls when its cached token has expired.
|
|
69
|
+
*
|
|
70
|
+
* An authentication-flows block discards the cache and names the flow to stop using. It does
|
|
71
|
+
* not say "sign in again".
|
|
72
|
+
*
|
|
73
|
+
* Why not: a blocked-flow refusal is the provider's policy refusing the flow (see
|
|
74
|
+
* `identity/device-code.ts`), so the trigger is a tenant admin enabling a policy, not a
|
|
75
|
+
* reconfiguration here. A refresher that cleared the cache and told the operator to sign in again
|
|
76
|
+
* would loop: `protocolFor` derives the flow from static config, so "again" would mean the same
|
|
77
|
+
* device-code flow, into the same policy. Unattended, that is refresh, blocked, clear, device code,
|
|
78
|
+
* blocked, repeat, never holding a usable token. A recovery path that returns to the failing state
|
|
79
|
+
* is not a recovery path.
|
|
80
|
+
*
|
|
81
|
+
* So it routes to the flow that is not blocked. The policy targets device code; loopback
|
|
82
|
+
* authorization-code plus PKCE is implemented here (`identity/authorize.ts`, `identity/pkce.ts`).
|
|
83
|
+
* When loopback is available the refusal says to use it; when it is not, the refusal refuses by
|
|
84
|
+
* name and tells the operator which setting to change, rather than inviting a retry that cannot
|
|
85
|
+
* succeed.
|
|
86
|
+
*/
|
|
87
|
+
export declare function refresherFor(config: IdentityConfig, endpoints: IdentityEndpoints, store: TokenStore, deps?: SignInDeps): TokenRefresher;
|
|
88
|
+
/** Which flow a host configured this way will use. One place, so the cache is stamped consistently. */
|
|
89
|
+
export declare function protocolFor(config: IdentityConfig): AuthProtocol;
|
|
90
|
+
//# sourceMappingURL=sign-in.d.ts.map
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { ok, refuse } from '../core/result.js';
|
|
2
|
+
import { systemTicker } from '../core/time.js';
|
|
3
|
+
import { discoveryUrl, readDiscoveryDocument } from '../identity/config.js';
|
|
4
|
+
import { buildAuthorizationUrl, readAuthorizationCallback } from '../identity/authorize.js';
|
|
5
|
+
import { createPkce, createState } from '../identity/pkce.js';
|
|
6
|
+
import { deviceAuthorizationBody, devicePollBody, AUTH_FLOW_BLOCKED_ERROR, isAuthFlowBlocked, readDeviceAuthorization, readDevicePoll, requireDeviceCodeEnabled, } from '../identity/device-code.js';
|
|
7
|
+
import { codeExchangeBody, readTokenResponse, refreshBody } from '../identity/token.js';
|
|
8
|
+
import { DEFAULT_SIGN_IN_TIMEOUT_MS, openLoopbackListener } from './loopback.js';
|
|
9
|
+
const FORM = { 'content-type': 'application/x-www-form-urlencoded' };
|
|
10
|
+
/** A `fetch` that never throws: a transport error becomes a named refusal like any other outcome. */
|
|
11
|
+
async function post(fetchImpl, url, body) {
|
|
12
|
+
let response;
|
|
13
|
+
try {
|
|
14
|
+
response = await fetchImpl(url, { method: 'POST', headers: FORM, body });
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
// A transport failure is an outage, not a refusal by the provider. Reading them as the same
|
|
18
|
+
// thing is how a network blip gets diagnosed as a bad credential.
|
|
19
|
+
return refuse('token-request-failed', `the token endpoint could not be reached: ${String(error)}`);
|
|
20
|
+
}
|
|
21
|
+
return ok({ status: response.status, body: await readJson(response) });
|
|
22
|
+
}
|
|
23
|
+
/** A body that is not JSON is `null`, and the reader above decides what that means. */
|
|
24
|
+
async function readJson(response) {
|
|
25
|
+
try {
|
|
26
|
+
return await response.json();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The endpoints, from configuration or from the provider's discovery document.
|
|
34
|
+
*
|
|
35
|
+
* Stated endpoints win and skip the network entirely — an operator who pinned them gets no
|
|
36
|
+
* surprise round trip, and an air-gapped or proxied deployment can work without discovery at all.
|
|
37
|
+
*/
|
|
38
|
+
export async function resolveEndpoints(config, fetchImpl = globalThis.fetch) {
|
|
39
|
+
if (config.endpoints !== null)
|
|
40
|
+
return ok(config.endpoints);
|
|
41
|
+
let response;
|
|
42
|
+
try {
|
|
43
|
+
response = await fetchImpl(discoveryUrl(config.authority));
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
return refuse('identity-config-invalid', `the provider's discovery document could not be fetched: ${String(error)}`);
|
|
47
|
+
}
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
return refuse('identity-config-invalid', `the provider's discovery document returned HTTP ${response.status}`);
|
|
50
|
+
}
|
|
51
|
+
return readDiscoveryDocument(await readJson(response));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Sign a user in through the loopback redirect, and persist what comes back.
|
|
55
|
+
*
|
|
56
|
+
* The listener is opened before the authorization URL is built, because the redirect URI has to
|
|
57
|
+
* carry the port the OS actually gave. Building the URL first would mean either guessing a port
|
|
58
|
+
* or fixing one, and a fixed port collides with whatever else is running on a developer's machine.
|
|
59
|
+
*
|
|
60
|
+
* And the listener is closed on every path. It is a socket accepting callbacks; leaving it open
|
|
61
|
+
* after a failed exchange would leave the thing this flow exists to bound still bound.
|
|
62
|
+
*/
|
|
63
|
+
export async function signIn(config, store, deps = {}) {
|
|
64
|
+
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
|
65
|
+
const nowMs = deps.nowMs ?? systemTicker;
|
|
66
|
+
const present = deps.present ?? ((url) => process.stdout.write(`open this to sign in:\n${url}\n`));
|
|
67
|
+
const endpoints = await resolveEndpoints(config, fetchImpl);
|
|
68
|
+
if (!endpoints.ok)
|
|
69
|
+
return refuse(endpoints.refusal.reason, endpoints.refusal.detail);
|
|
70
|
+
const opened = await openLoopbackListener(config.redirectPort, deps.timeoutMs ?? DEFAULT_SIGN_IN_TIMEOUT_MS);
|
|
71
|
+
if (!opened.ok)
|
|
72
|
+
return refuse(opened.refusal.reason, opened.refusal.detail);
|
|
73
|
+
const listener = opened.value;
|
|
74
|
+
try {
|
|
75
|
+
const pkce = createPkce();
|
|
76
|
+
const state = createState();
|
|
77
|
+
const request = buildAuthorizationUrl(config, endpoints.value.authorizationEndpoint, pkce, state, listener.redirectUri);
|
|
78
|
+
present(request.url);
|
|
79
|
+
const arrived = await listener.callback;
|
|
80
|
+
if (!arrived.ok)
|
|
81
|
+
return refuse(arrived.refusal.reason, arrived.refusal.detail);
|
|
82
|
+
// The `state` check lives here, in the pure reader, and it runs before the code is touched.
|
|
83
|
+
const callback = readAuthorizationCallback(arrived.value, state);
|
|
84
|
+
if (!callback.ok)
|
|
85
|
+
return refuse(callback.refusal.reason, callback.refusal.detail);
|
|
86
|
+
const exchanged = await post(fetchImpl, endpoints.value.tokenEndpoint, codeExchangeBody(config, callback.value.code, pkce.verifier, listener.redirectUri));
|
|
87
|
+
if (!exchanged.ok)
|
|
88
|
+
return refuse(exchanged.refusal.reason, exchanged.refusal.detail);
|
|
89
|
+
const tokens = readTokenResponse(exchanged.value.status, exchanged.value.body, nowMs());
|
|
90
|
+
if (!tokens.ok)
|
|
91
|
+
return refuse(tokens.refusal.reason, tokens.refusal.detail);
|
|
92
|
+
const cached = {
|
|
93
|
+
tokens: tokens.value,
|
|
94
|
+
protocol: 'loopback',
|
|
95
|
+
authority: config.authority,
|
|
96
|
+
clientId: config.clientId,
|
|
97
|
+
};
|
|
98
|
+
// A cache that would not write is a failed sign-in, and that is the opposite of the rule in
|
|
99
|
+
// `credential.ts`. There, a refresh had already succeeded and the token was in hand. Here the
|
|
100
|
+
// whole point was to persist it: returning success would tell the operator they are signed in
|
|
101
|
+
// while the next start finds nothing.
|
|
102
|
+
const written = store.write(cached);
|
|
103
|
+
if (!written.ok)
|
|
104
|
+
return refuse(written.refusal.reason, written.refusal.detail);
|
|
105
|
+
return ok(cached);
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
listener.close();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Sign in through the device-code flow, for a box with no browser on it.
|
|
113
|
+
*
|
|
114
|
+
* It refuses unless it was explicitly enabled, and that check is the first thing here. The
|
|
115
|
+
* provider calls this a high-risk method and recommends blocking it; a tenant refusing it outright
|
|
116
|
+
* is an expected outcome. It must be asked for by name, never arrived at by falling back — see
|
|
117
|
+
* `identity/device-code.ts` for the policy-blocked-flow trap it also carries.
|
|
118
|
+
*
|
|
119
|
+
* The poll interval is the provider's, not this package's, and `slow_down` widens it. Polling faster than
|
|
120
|
+
* asked is how a client gets rate-limited into failing a sign-in that would have worked.
|
|
121
|
+
*/
|
|
122
|
+
export async function signInWithDeviceCode(config, store, instruct, deps = {}) {
|
|
123
|
+
const enabled = requireDeviceCodeEnabled(config);
|
|
124
|
+
if (!enabled.ok)
|
|
125
|
+
return refuse(enabled.refusal.reason, enabled.refusal.detail);
|
|
126
|
+
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
|
127
|
+
const nowMs = deps.nowMs ?? systemTicker;
|
|
128
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
129
|
+
const endpoints = await resolveEndpoints(config, fetchImpl);
|
|
130
|
+
if (!endpoints.ok)
|
|
131
|
+
return refuse(endpoints.refusal.reason, endpoints.refusal.detail);
|
|
132
|
+
const deviceEndpoint = endpoints.value.deviceAuthorizationEndpoint;
|
|
133
|
+
if (deviceEndpoint === null) {
|
|
134
|
+
return refuse('device-code-declined', 'this provider does not advertise a device authorization endpoint');
|
|
135
|
+
}
|
|
136
|
+
const started = await post(fetchImpl, deviceEndpoint, deviceAuthorizationBody(config));
|
|
137
|
+
if (!started.ok)
|
|
138
|
+
return refuse(started.refusal.reason, started.refusal.detail);
|
|
139
|
+
const authorization = readDeviceAuthorization(started.value.status, started.value.body, nowMs());
|
|
140
|
+
if (!authorization.ok)
|
|
141
|
+
return refuse(authorization.refusal.reason, authorization.refusal.detail);
|
|
142
|
+
instruct({ verificationUri: authorization.value.verificationUri, userCode: authorization.value.userCode });
|
|
143
|
+
let intervalMs = authorization.value.intervalMs;
|
|
144
|
+
const body = devicePollBody(config, authorization.value.deviceCode);
|
|
145
|
+
while (nowMs() < authorization.value.expiresAt) {
|
|
146
|
+
await sleep(intervalMs);
|
|
147
|
+
const polled = await post(fetchImpl, endpoints.value.tokenEndpoint, body);
|
|
148
|
+
if (!polled.ok)
|
|
149
|
+
return refuse(polled.refusal.reason, polled.refusal.detail);
|
|
150
|
+
const outcome = readDevicePoll(polled.value.status, polled.value.body, intervalMs);
|
|
151
|
+
if (outcome.kind === 'pending')
|
|
152
|
+
continue;
|
|
153
|
+
if (outcome.kind === 'slow-down') {
|
|
154
|
+
intervalMs = outcome.intervalMs;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (outcome.kind === 'declined')
|
|
158
|
+
return refuse(outcome.refusal.reason, outcome.refusal.detail);
|
|
159
|
+
const tokens = readTokenResponse(polled.value.status, polled.value.body, nowMs());
|
|
160
|
+
if (!tokens.ok)
|
|
161
|
+
return refuse(tokens.refusal.reason, tokens.refusal.detail);
|
|
162
|
+
const cached = {
|
|
163
|
+
tokens: tokens.value,
|
|
164
|
+
// Stamped `device-code`, and that stamp is what makes the blocked-flow guard possible. A
|
|
165
|
+
// cache that does not record which flow minted it cannot be refreshed safely by either.
|
|
166
|
+
protocol: 'device-code',
|
|
167
|
+
authority: config.authority,
|
|
168
|
+
clientId: config.clientId,
|
|
169
|
+
};
|
|
170
|
+
const written = store.write(cached);
|
|
171
|
+
if (!written.ok)
|
|
172
|
+
return refuse(written.refusal.reason, written.refusal.detail);
|
|
173
|
+
return ok(cached);
|
|
174
|
+
}
|
|
175
|
+
return refuse('device-code-declined', 'the device code expired before the sign-in was completed');
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The refresher `TokenCredential` calls when its cached token has expired.
|
|
179
|
+
*
|
|
180
|
+
* An authentication-flows block discards the cache and names the flow to stop using. It does
|
|
181
|
+
* not say "sign in again".
|
|
182
|
+
*
|
|
183
|
+
* Why not: a blocked-flow refusal is the provider's policy refusing the flow (see
|
|
184
|
+
* `identity/device-code.ts`), so the trigger is a tenant admin enabling a policy, not a
|
|
185
|
+
* reconfiguration here. A refresher that cleared the cache and told the operator to sign in again
|
|
186
|
+
* would loop: `protocolFor` derives the flow from static config, so "again" would mean the same
|
|
187
|
+
* device-code flow, into the same policy. Unattended, that is refresh, blocked, clear, device code,
|
|
188
|
+
* blocked, repeat, never holding a usable token. A recovery path that returns to the failing state
|
|
189
|
+
* is not a recovery path.
|
|
190
|
+
*
|
|
191
|
+
* So it routes to the flow that is not blocked. The policy targets device code; loopback
|
|
192
|
+
* authorization-code plus PKCE is implemented here (`identity/authorize.ts`, `identity/pkce.ts`).
|
|
193
|
+
* When loopback is available the refusal says to use it; when it is not, the refusal refuses by
|
|
194
|
+
* name and tells the operator which setting to change, rather than inviting a retry that cannot
|
|
195
|
+
* succeed.
|
|
196
|
+
*/
|
|
197
|
+
export function refresherFor(config, endpoints, store, deps = {}) {
|
|
198
|
+
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
|
199
|
+
const nowMs = deps.nowMs ?? systemTicker;
|
|
200
|
+
return async (cached) => {
|
|
201
|
+
const refreshToken = cached.tokens.refreshToken;
|
|
202
|
+
if (refreshToken === null) {
|
|
203
|
+
return refuse('token-unavailable', 'the cached token has no refresh token, so it cannot be renewed without signing in');
|
|
204
|
+
}
|
|
205
|
+
const response = await post(fetchImpl, endpoints.tokenEndpoint, refreshBody(config, refreshToken));
|
|
206
|
+
if (!response.ok)
|
|
207
|
+
return refuse(response.refusal.reason, response.refusal.detail);
|
|
208
|
+
const tokens = readTokenResponse(response.value.status, response.value.body, nowMs());
|
|
209
|
+
if (!tokens.ok) {
|
|
210
|
+
if (isAuthFlowBlocked(tokens.refusal.detail)) {
|
|
211
|
+
// Cleared either way: the provider's own guidance is that such material is unusable for good
|
|
212
|
+
// and should be deleted. What differs is what the operator is told to do next.
|
|
213
|
+
store.clear();
|
|
214
|
+
// The fallback is only a fallback if it is a different flow. Naming loopback while the
|
|
215
|
+
// host is configured for loopback would be the same loop with a new label.
|
|
216
|
+
const blockedFlow = protocolFor(config);
|
|
217
|
+
if (blockedFlow === 'device-code') {
|
|
218
|
+
return refuse('token-unavailable', `the identity provider's policy has blocked the DEVICE CODE flow this material was minted by ` +
|
|
219
|
+
`(provider code ${AUTH_FLOW_BLOCKED_ERROR}); the token can never be refreshed and the cache has been ` +
|
|
220
|
+
`discarded. Do not re-run the device code flow: the same policy will block it, every time. Set ` +
|
|
221
|
+
`deviceCodeEnabled: false (for the binary, unset PERISCOPE_IDENTITY_DEVICE_CODE) to use the loopback ` +
|
|
222
|
+
`authorization-code (PKCE) flow, which such a policy does not target, or have the provider's ` +
|
|
223
|
+
`administrator exempt this application.`);
|
|
224
|
+
}
|
|
225
|
+
return refuse('token-unavailable', `the identity provider's policy has blocked this material (provider code ${AUTH_FLOW_BLOCKED_ERROR}) ` +
|
|
226
|
+
`and the cache has been discarded. This host is ALREADY configured for the loopback flow, so there is ` +
|
|
227
|
+
`no unblocked flow left to fall back to: the policy must be changed by the provider's administrator. ` +
|
|
228
|
+
`Retrying will not help.`);
|
|
229
|
+
}
|
|
230
|
+
return refuse(tokens.refusal.reason, tokens.refusal.detail);
|
|
231
|
+
}
|
|
232
|
+
return tokens;
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/** Which flow a host configured this way will use. One place, so the cache is stamped consistently. */
|
|
236
|
+
export function protocolFor(config) {
|
|
237
|
+
return config.deviceCodeEnabled ? 'device-code' : 'loopback';
|
|
238
|
+
}
|
|
239
|
+
//# sourceMappingURL=sign-in.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifting cost and rate-limit facts off the agent's own stream.
|
|
3
|
+
*
|
|
4
|
+
* In `src/host/` because it names the SDK's message union, the same reason `readInitFacts` is there.
|
|
5
|
+
* The layer above branches on plain records and never learns the SDK's shapes.
|
|
6
|
+
*
|
|
7
|
+
* What is deliberately absent, and it is not an oversight: two facts a dashboard wants,
|
|
8
|
+
* `getContextUsage()` for the context ring and `accountInfo()` for the account's own limits, are
|
|
9
|
+
* methods on the query object, and this package does not hand that object out: it is the narrowing
|
|
10
|
+
* that keeps four mid-session permission mutators unreachable, and it is pinned. Reaching them means
|
|
11
|
+
* either widening that narrowing or adding a named method beside `prompt` and `interrupt`, and both
|
|
12
|
+
* are decisions above this file. Everything below arrives on the message stream, which this host
|
|
13
|
+
* already has, so none of it costs that trade.
|
|
14
|
+
*/
|
|
15
|
+
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
16
|
+
import type { RateLimitStanding, TurnSpend } from '../telemetry/usage.js';
|
|
17
|
+
/**
|
|
18
|
+
* A turn's spend, or null for every message that is not a result.
|
|
19
|
+
*
|
|
20
|
+
* Every figure is copied, none computed. No token count is multiplied by anything here; the
|
|
21
|
+
* agent priced the turn and this carries what it said. That is what makes a mixed-model turn correct
|
|
22
|
+
* without this package holding a price table that would rot.
|
|
23
|
+
*/
|
|
24
|
+
export declare function readTurnSpend(message: SDKMessage): TurnSpend | null;
|
|
25
|
+
/**
|
|
26
|
+
* Where a rate limit stands, or null for every other message.
|
|
27
|
+
*
|
|
28
|
+
* It arrives in-stream, which is the part worth noticing: no unofficial endpoint has to be polled
|
|
29
|
+
* to learn this. Nothing here polls anything.
|
|
30
|
+
*/
|
|
31
|
+
export declare function readRateLimit(message: SDKMessage): RateLimitStanding | null;
|
|
32
|
+
/**
|
|
33
|
+
* A subagent's own usage, off the task notification that reports it.
|
|
34
|
+
*
|
|
35
|
+
* This is what makes a subagent's cost attributable: the agent reports per-task usage, and hooks
|
|
36
|
+
* carry an agent id, so spend has an owner without anything being inferred.
|
|
37
|
+
*/
|
|
38
|
+
export declare function readTaskSpend(message: SDKMessage): {
|
|
39
|
+
agentId: string;
|
|
40
|
+
spend: TurnSpend;
|
|
41
|
+
} | null;
|
|
42
|
+
//# sourceMappingURL=telemetry.d.ts.map
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A turn's spend, or null for every message that is not a result.
|
|
3
|
+
*
|
|
4
|
+
* Every figure is copied, none computed. No token count is multiplied by anything here; the
|
|
5
|
+
* agent priced the turn and this carries what it said. That is what makes a mixed-model turn correct
|
|
6
|
+
* without this package holding a price table that would rot.
|
|
7
|
+
*/
|
|
8
|
+
export function readTurnSpend(message) {
|
|
9
|
+
if (message.type !== 'result')
|
|
10
|
+
return null;
|
|
11
|
+
const byModel = Object.entries(message.modelUsage ?? {}).map(([model, usage]) => ({
|
|
12
|
+
model,
|
|
13
|
+
costUsd: usage.costUSD,
|
|
14
|
+
inputTokens: usage.inputTokens,
|
|
15
|
+
outputTokens: usage.outputTokens,
|
|
16
|
+
cacheReadInputTokens: usage.cacheReadInputTokens,
|
|
17
|
+
cacheCreationInputTokens: usage.cacheCreationInputTokens,
|
|
18
|
+
// Absent is null rather than zero: a zero window reads as a real, tiny limit.
|
|
19
|
+
contextWindow: usage.contextWindow ?? null,
|
|
20
|
+
canonicalModel: usage.canonicalModel ?? null,
|
|
21
|
+
provider: usage.provider ?? null,
|
|
22
|
+
}));
|
|
23
|
+
return { totalCostUsd: message.total_cost_usd, byModel };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Where a rate limit stands, or null for every other message.
|
|
27
|
+
*
|
|
28
|
+
* It arrives in-stream, which is the part worth noticing: no unofficial endpoint has to be polled
|
|
29
|
+
* to learn this. Nothing here polls anything.
|
|
30
|
+
*/
|
|
31
|
+
export function readRateLimit(message) {
|
|
32
|
+
if (message.type !== 'rate_limit_event')
|
|
33
|
+
return null;
|
|
34
|
+
const info = message.rate_limit_info;
|
|
35
|
+
return {
|
|
36
|
+
status: info.status,
|
|
37
|
+
resetsAt: info.resetsAt ?? null,
|
|
38
|
+
limitType: info.rateLimitType ?? null,
|
|
39
|
+
utilization: info.utilization ?? null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A subagent's own usage, off the task notification that reports it.
|
|
44
|
+
*
|
|
45
|
+
* This is what makes a subagent's cost attributable: the agent reports per-task usage, and hooks
|
|
46
|
+
* carry an agent id, so spend has an owner without anything being inferred.
|
|
47
|
+
*/
|
|
48
|
+
export function readTaskSpend(message) {
|
|
49
|
+
if (message.type !== 'system' || message.subtype !== 'task_notification')
|
|
50
|
+
return null;
|
|
51
|
+
const usage = message.model_usage;
|
|
52
|
+
const totalCost = message.total_cost_usd;
|
|
53
|
+
const agentId = message.agent_id;
|
|
54
|
+
if (usage === undefined || totalCost === undefined || agentId === undefined)
|
|
55
|
+
return null;
|
|
56
|
+
return {
|
|
57
|
+
agentId,
|
|
58
|
+
spend: {
|
|
59
|
+
totalCostUsd: totalCost,
|
|
60
|
+
byModel: Object.entries(usage).map(([model, one]) => ({
|
|
61
|
+
model,
|
|
62
|
+
costUsd: one.costUSD,
|
|
63
|
+
inputTokens: one.inputTokens,
|
|
64
|
+
outputTokens: one.outputTokens,
|
|
65
|
+
cacheReadInputTokens: one.cacheReadInputTokens,
|
|
66
|
+
cacheCreationInputTokens: one.cacheCreationInputTokens,
|
|
67
|
+
contextWindow: one.contextWindow ?? null,
|
|
68
|
+
canonicalModel: one.canonicalModel ?? null,
|
|
69
|
+
provider: one.provider ?? null,
|
|
70
|
+
})),
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=telemetry.js.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Result } from '../core/result.js';
|
|
2
|
+
import type { CredentialModeOutcome, ModeEnforcement } from '../identity/mode.js';
|
|
3
|
+
import type { CachedTokens, TokenStore } from '../identity/store.js';
|
|
4
|
+
export interface TokenCacheWrite {
|
|
5
|
+
readonly path: string;
|
|
6
|
+
readonly mode: CredentialModeOutcome;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Measure what this filesystem does with a mode.
|
|
10
|
+
*
|
|
11
|
+
* Measured rather than listed by platform. A `process.platform === 'win32'` test is a guess that
|
|
12
|
+
* ages: WSL, a POSIX filesystem mounted under Windows, and a future runtime mapping modes onto ACLs
|
|
13
|
+
* would each make it wrong in the direction that matters. The probe writes a throwaway file BESIDE
|
|
14
|
+
* the cache — the same filesystem, which is the thing actually in question.
|
|
15
|
+
*
|
|
16
|
+
* It asserts its positive control before it reports the negative, and that ordering is the whole
|
|
17
|
+
* design. Three measurements, in this order:
|
|
18
|
+
*
|
|
19
|
+
* 1. Ask for `0600` and for `0666`. If they read back differently AND `0600` came back as `0600`,
|
|
20
|
+
* POSIX modes are honoured. Done.
|
|
21
|
+
* 2. They did not differ — so privacy is unconfirmable. **Before saying so, prove the instrument
|
|
22
|
+
* is alive**: clear the write bit with `0444`. Measured on win32, that DOES read back as
|
|
23
|
+
* `444`, because the write bit is the one real bit there. If it changes, this is a genuine
|
|
24
|
+
* finding about a filesystem that records only writability.
|
|
25
|
+
* 3. Even `0444` changed nothing. Then the probe cannot distinguish "this filesystem records
|
|
26
|
+
* nothing" from "this probe is broken", and it says exactly that.
|
|
27
|
+
*
|
|
28
|
+
* Without step 2, "unenforced" and "the probe never worked" are the same answer — an instrument
|
|
29
|
+
* with no inconclusive state, which is how a dead check gets read as evidence.
|
|
30
|
+
*
|
|
31
|
+
* `0444` is the control, never the target. The cache must stay owner-writable; refreshes are
|
|
32
|
+
* written to it. The probe restores a writable mode on its own throwaway file before removing it,
|
|
33
|
+
* because a read-only file is one `rmSync` can fail on.
|
|
34
|
+
*/
|
|
35
|
+
export declare function probeModeEnforcement(directory: string): ModeEnforcement;
|
|
36
|
+
export declare class FileTokenCache implements TokenStore {
|
|
37
|
+
#private;
|
|
38
|
+
constructor(path: string);
|
|
39
|
+
get path(): string;
|
|
40
|
+
/** Memoised: the filesystem does not change its mind mid-process. */
|
|
41
|
+
modeEnforcement(): ModeEnforcement;
|
|
42
|
+
/**
|
|
43
|
+
* Read the cache.
|
|
44
|
+
*
|
|
45
|
+
* A missing file is `token-unavailable` — nobody has signed in — and that is deliberately NOT the
|
|
46
|
+
* same reason as a file that exists and cannot be parsed, which is a real problem with a real
|
|
47
|
+
* fix. Collapsing them would make a corrupted cache look like a fresh install forever.
|
|
48
|
+
*/
|
|
49
|
+
read(): Result<CachedTokens>;
|
|
50
|
+
/**
|
|
51
|
+
* Write the cache, then verify what actually landed.
|
|
52
|
+
*
|
|
53
|
+
* A file that came out wider than asked is removed, not left and reported. Leaving a
|
|
54
|
+
* world-readable token on disk while returning a refusal would mean the caller sees a failure and
|
|
55
|
+
* the credential is exposed anyway — the worst of both. No cache at all is strictly safer than a
|
|
56
|
+
* readable one, and the sign-in can simply be repeated.
|
|
57
|
+
*/
|
|
58
|
+
write(cached: CachedTokens): Result<TokenCacheWrite>;
|
|
59
|
+
/** Remove the cache. Idempotent — clearing a cache that is not there is success, not an error. */
|
|
60
|
+
clear(): void;
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=token-cache.d.ts.map
|