@lensagent/core 0.1.0 → 0.1.2
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/README.md +63 -4
- package/config/lens.example.jsonc +36 -13
- package/dist/adapters/executor-router.d.ts +20 -0
- package/dist/adapters/executor-router.js +86 -0
- package/dist/adapters/executor.d.ts +9 -3
- package/dist/adapters/executor.js +196 -61
- package/dist/adapters/lsp.d.ts +2 -1
- package/dist/adapters/lsp.js +98 -41
- package/dist/adapters/mcp.js +52 -17
- package/dist/adapters/revert.d.ts +5 -4
- package/dist/adapters/revert.js +6 -64
- package/dist/adapters/web.d.ts +9 -0
- package/dist/adapters/web.js +244 -0
- package/dist/config/files.d.ts +14 -0
- package/dist/config/files.js +57 -0
- package/dist/config/runtime.d.ts +3 -4
- package/dist/config/runtime.js +116 -97
- package/dist/config/schema.d.ts +56 -10
- package/dist/config/schema.js +48 -8
- package/dist/contracts/capability.d.ts +27 -1
- package/dist/contracts/domain.d.ts +1 -0
- package/dist/contracts/model.d.ts +33 -6
- package/dist/contracts/review.d.ts +17 -0
- package/dist/contracts/review.js +1 -0
- package/dist/core/access-policy.d.ts +40 -0
- package/dist/core/access-policy.js +68 -0
- package/dist/core/access.d.ts +30 -2
- package/dist/core/access.js +159 -26
- package/dist/core/context-boundaries.d.ts +25 -0
- package/dist/core/context-boundaries.js +106 -0
- package/dist/core/context.d.ts +3 -0
- package/dist/core/context.js +11 -0
- package/dist/core/host.js +261 -173
- package/dist/entry/pair.d.ts +1 -0
- package/dist/entry/pair.js +14 -0
- package/dist/entry/run.js +1 -1
- package/dist/execution/lsp-process.d.ts +3 -2
- package/dist/execution/lsp-process.js +46 -28
- package/dist/http/server.d.ts +5 -0
- package/dist/http/server.js +262 -80
- package/dist/runtime/agent.d.ts +9 -4
- package/dist/runtime/agent.js +361 -286
- package/dist/runtime/artifacts.d.ts +13 -1
- package/dist/runtime/artifacts.js +137 -10
- package/dist/runtime/cache.d.ts +59 -16
- package/dist/runtime/cache.js +124 -43
- package/dist/runtime/calls.d.ts +1 -1
- package/dist/runtime/calls.js +164 -48
- package/dist/runtime/capabilities.d.ts +2 -2
- package/dist/runtime/capabilities.js +4 -5
- package/dist/runtime/cleanup.d.ts +5 -2
- package/dist/runtime/cleanup.js +48 -17
- package/dist/runtime/compaction.d.ts +9 -0
- package/dist/runtime/compaction.js +52 -0
- package/dist/runtime/context.d.ts +8 -4
- package/dist/runtime/context.js +115 -128
- package/dist/runtime/events.d.ts +7 -0
- package/dist/runtime/events.js +83 -0
- package/dist/runtime/images.d.ts +14 -0
- package/dist/runtime/images.js +82 -0
- package/dist/runtime/kernel-capabilities.d.ts +7 -1
- package/dist/runtime/kernel-capabilities.js +146 -68
- package/dist/runtime/model-audit.d.ts +9 -0
- package/dist/runtime/model-audit.js +39 -0
- package/dist/runtime/model.d.ts +109 -4
- package/dist/runtime/model.js +368 -143
- package/dist/runtime/observability.d.ts +1 -0
- package/dist/runtime/observability.js +60 -15
- package/dist/runtime/policy.d.ts +1 -1
- package/dist/runtime/policy.js +2 -2
- package/dist/runtime/profile.d.ts +2 -1
- package/dist/runtime/profile.js +12 -15
- package/dist/runtime/projects.d.ts +3 -1
- package/dist/runtime/projects.js +38 -17
- package/dist/runtime/ptc-worker.d.ts +1 -0
- package/dist/runtime/ptc-worker.js +97 -0
- package/dist/runtime/ptc.d.ts +16 -0
- package/dist/runtime/ptc.js +128 -0
- package/dist/runtime/results.d.ts +5 -4
- package/dist/runtime/results.js +29 -51
- package/dist/runtime/review.d.ts +4 -1
- package/dist/runtime/review.js +101 -22
- package/dist/runtime/scheduler.js +1 -0
- package/dist/runtime/skills.js +34 -5
- package/dist/runtime/task-audit.d.ts +21 -0
- package/dist/runtime/task-audit.js +122 -0
- package/dist/runtime/time.d.ts +9 -0
- package/dist/runtime/time.js +18 -0
- package/dist/runtime/tokenizer.d.ts +2 -0
- package/dist/runtime/tokenizer.js +26 -1
- package/dist/store/database.js +26 -1
- package/dist/store/event-log.d.ts +1 -0
- package/dist/store/event-log.js +22 -5
- package/dist/store/schema.d.ts +1 -1
- package/dist/store/schema.js +50 -1
- package/dist/store/store.d.ts +65 -19
- package/dist/store/store.js +986 -188
- package/package.json +18 -8
- package/proto/executor.proto +69 -55
|
@@ -8,25 +8,34 @@ export interface CapabilityDefinition {
|
|
|
8
8
|
readonly effect: EffectScope;
|
|
9
9
|
readonly adapterId: string;
|
|
10
10
|
readonly loadMode: 'eager' | 'deferred';
|
|
11
|
+
/** Adapter metadata, not a universal byte cap; concrete adapters enforce their own limits. */
|
|
11
12
|
readonly outputLimitBytes: number;
|
|
12
13
|
readonly defaultAssignment?: 'all_new_agents' | 'configured' | 'none';
|
|
13
|
-
readonly kind?: 'tool' | '
|
|
14
|
+
readonly kind?: 'tool' | 'slash_command' | 'skill';
|
|
14
15
|
readonly promptText?: string;
|
|
15
16
|
}
|
|
16
17
|
export interface CapabilityInvocation {
|
|
17
18
|
readonly callId: string;
|
|
19
|
+
readonly taskId?: string;
|
|
18
20
|
readonly projectId: string;
|
|
19
21
|
readonly agentId: string | null;
|
|
20
22
|
readonly capabilityId: string;
|
|
21
23
|
readonly arguments: Json;
|
|
24
|
+
readonly continuation?: {
|
|
25
|
+
readonly prompt?: Json;
|
|
26
|
+
readonly answer?: Json;
|
|
27
|
+
};
|
|
22
28
|
readonly workspacePaths: readonly string[];
|
|
23
29
|
readonly workspaceId: string;
|
|
24
30
|
readonly fullAccess?: boolean;
|
|
25
31
|
readonly signal: AbortSignal;
|
|
26
32
|
}
|
|
33
|
+
export type CaptureStatus = 'complete' | 'partial' | 'unknown';
|
|
27
34
|
export interface CapabilityOutput {
|
|
35
|
+
readonly capture?: CaptureStatus;
|
|
28
36
|
readonly content: Json;
|
|
29
37
|
readonly artifacts?: readonly ArtifactOutput[];
|
|
38
|
+
readonly artifactReferences?: readonly string[];
|
|
30
39
|
readonly outcome?: 'success' | 'failed';
|
|
31
40
|
readonly errorCode?: string;
|
|
32
41
|
}
|
|
@@ -35,6 +44,23 @@ export interface ArtifactOutput {
|
|
|
35
44
|
readonly mediaType: string;
|
|
36
45
|
readonly bytes: Uint8Array;
|
|
37
46
|
}
|
|
47
|
+
/** Non-PTC Result view shared by automatic Yield and manual Include. */
|
|
48
|
+
export type ResultEnvelope = {
|
|
49
|
+
readonly result_id: string;
|
|
50
|
+
readonly call_id: string;
|
|
51
|
+
readonly outcome: string;
|
|
52
|
+
readonly outcome_unknown: boolean;
|
|
53
|
+
readonly error_code: string | null;
|
|
54
|
+
readonly error_message: string | null;
|
|
55
|
+
readonly content: Json;
|
|
56
|
+
readonly artifacts: readonly string[];
|
|
57
|
+
readonly truncated: boolean;
|
|
58
|
+
readonly payload: {
|
|
59
|
+
readonly capture: CaptureStatus;
|
|
60
|
+
readonly stored: 'full' | 'unknown';
|
|
61
|
+
readonly view: 'full' | 'preview';
|
|
62
|
+
};
|
|
63
|
+
};
|
|
38
64
|
export interface CapabilityAdapter {
|
|
39
65
|
readonly id: string;
|
|
40
66
|
execute(invocation: CapabilityInvocation): Promise<CapabilityOutput>;
|
|
@@ -10,6 +10,7 @@ export type WaitReason = 'user_input' | 'policy_review';
|
|
|
10
10
|
export type Policy = 'read_only' | 'workspace_write' | 'external' | 'ask' | 'auto_approve' | 'full_access';
|
|
11
11
|
export type EffectScope = 'read_only' | 'workspace_write' | 'external' | 'full';
|
|
12
12
|
export interface TrustedIngress {
|
|
13
|
+
readonly strongTrust: boolean;
|
|
13
14
|
readonly source: 'user';
|
|
14
15
|
readonly clientId: string;
|
|
15
16
|
readonly transport: 'http' | 'https';
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import type { Json } from './domain.js';
|
|
2
|
+
import type { AssistantModelMessage } from 'ai';
|
|
3
|
+
/** Completed, ordered SDK content; signatures and encrypted state remain opaque. */
|
|
4
|
+
export interface ModelResponse {
|
|
5
|
+
readonly route: string;
|
|
6
|
+
readonly content: Exclude<AssistantModelMessage['content'], string>;
|
|
7
|
+
}
|
|
2
8
|
export interface ModelMessage {
|
|
3
9
|
readonly role: 'system' | 'user' | 'assistant' | 'tool';
|
|
4
10
|
readonly content: string;
|
|
11
|
+
readonly response?: ModelResponse;
|
|
12
|
+
readonly images?: readonly ModelImage[];
|
|
13
|
+
readonly contextItemId?: string;
|
|
14
|
+
readonly metadata?: Readonly<Record<string, Json>>;
|
|
5
15
|
readonly toolCallId?: string;
|
|
6
16
|
readonly toolName?: string;
|
|
7
17
|
readonly toolCalls?: readonly {
|
|
@@ -10,6 +20,10 @@ export interface ModelMessage {
|
|
|
10
20
|
readonly arguments: Json;
|
|
11
21
|
}[];
|
|
12
22
|
}
|
|
23
|
+
export interface ModelImage {
|
|
24
|
+
readonly projectId: string;
|
|
25
|
+
readonly artifactId: string;
|
|
26
|
+
}
|
|
13
27
|
export interface ModelTool {
|
|
14
28
|
readonly id: string;
|
|
15
29
|
readonly description: string;
|
|
@@ -23,21 +37,31 @@ export interface ModelToolBridgeInput {
|
|
|
23
37
|
readonly ordinal: number;
|
|
24
38
|
readonly parentProviderCallId?: string;
|
|
25
39
|
}
|
|
26
|
-
export interface ProgrammaticToolCalling {
|
|
27
|
-
readonly capabilityIds: readonly string[];
|
|
28
|
-
readonly maxToolCalls: number;
|
|
29
|
-
execute(input: ModelToolBridgeInput, signal: AbortSignal | undefined): Promise<Json>;
|
|
30
|
-
}
|
|
31
40
|
export interface ModelRequest {
|
|
41
|
+
readonly auditContext?: {
|
|
42
|
+
readonly projectId: string;
|
|
43
|
+
readonly taskId?: string;
|
|
44
|
+
readonly agentId?: string;
|
|
45
|
+
readonly callId?: string;
|
|
46
|
+
readonly attemptId?: string;
|
|
47
|
+
readonly purpose: 'agent' | 'compaction' | 'auto-review';
|
|
48
|
+
};
|
|
32
49
|
readonly model: string;
|
|
50
|
+
readonly cacheIdentity?: string;
|
|
51
|
+
readonly toolChoice?: 'none';
|
|
33
52
|
readonly messages: readonly ModelMessage[];
|
|
34
53
|
readonly tools: readonly ModelTool[];
|
|
35
54
|
readonly reasoningEffort: string | null;
|
|
36
55
|
readonly maxOutputTokens?: number;
|
|
37
|
-
readonly programmaticToolCalling?: ProgrammaticToolCalling;
|
|
38
56
|
readonly signal: AbortSignal;
|
|
39
57
|
}
|
|
40
58
|
export type ModelStreamEvent = {
|
|
59
|
+
readonly type: 'response';
|
|
60
|
+
readonly response: ModelResponse;
|
|
61
|
+
} | {
|
|
62
|
+
readonly type: 'providerWarnings';
|
|
63
|
+
readonly warnings: Json;
|
|
64
|
+
} | {
|
|
41
65
|
readonly type: 'text';
|
|
42
66
|
readonly text: string;
|
|
43
67
|
} | {
|
|
@@ -68,6 +92,9 @@ export type ModelStreamEvent = {
|
|
|
68
92
|
readonly type: 'usage';
|
|
69
93
|
readonly inputTokens: number;
|
|
70
94
|
readonly outputTokens: number;
|
|
95
|
+
readonly cacheReadTokens?: number;
|
|
96
|
+
readonly cacheWriteTokens?: number;
|
|
97
|
+
readonly uncachedInputTokens?: number;
|
|
71
98
|
} | {
|
|
72
99
|
readonly type: 'end';
|
|
73
100
|
readonly reason: string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Json } from '../core/domain.js';
|
|
2
|
+
export interface ReviewContext {
|
|
3
|
+
readonly authorization: readonly Json[];
|
|
4
|
+
readonly workspacePaths: readonly string[];
|
|
5
|
+
readonly evidence: readonly Json[];
|
|
6
|
+
}
|
|
7
|
+
export type AutoReviewResult = {
|
|
8
|
+
readonly status: 'reviewed';
|
|
9
|
+
readonly risk_level: 'low' | 'medium' | 'high' | 'critical';
|
|
10
|
+
readonly user_authorization: 'unknown' | 'low' | 'medium' | 'high';
|
|
11
|
+
readonly outcome: 'allow' | 'deny';
|
|
12
|
+
readonly rationale: string;
|
|
13
|
+
} | {
|
|
14
|
+
readonly status: 'failed';
|
|
15
|
+
readonly error_code: string;
|
|
16
|
+
readonly rationale: string;
|
|
17
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may be approved without a person deciding.
|
|
3
|
+
*
|
|
4
|
+
* Separate from the store that applies it so the rule can be read and tested on its own. It governs access to
|
|
5
|
+
* Sessions, Providers and every file Capability Core exposes, which is not a decision to leave implicit in the
|
|
6
|
+
* middle of a request handler.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Whether a socket address is on this machine.
|
|
10
|
+
*
|
|
11
|
+
* Must be given the raw socket address. A forwarded header is supplied by the caller, so deriving locality from
|
|
12
|
+
* one would let anyone claim it.
|
|
13
|
+
*/
|
|
14
|
+
export declare function loopbackAddress(address: string | undefined): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Whether the request reached Core through something else.
|
|
17
|
+
*
|
|
18
|
+
* This is what stops a relay from lending its own locality to whoever is behind it. Facet's host proxies browser
|
|
19
|
+
* traffic to Core from loopback, so without this every caller that can reach Facet -- from any machine -- would
|
|
20
|
+
* look local to Core and be approved automatically.
|
|
21
|
+
*
|
|
22
|
+
* Presence is the whole test, and it does not matter whether the header can be trusted. A relay that sets it
|
|
23
|
+
* honestly is excluded, and a caller that forges one only manages to exclude itself.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isRelayed(headers: Readonly<Record<string, unknown>>): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Whether an unknown caller may be approved on first contact.
|
|
28
|
+
*
|
|
29
|
+
* Three conditions, each earning its place.
|
|
30
|
+
*
|
|
31
|
+
* The connection comes from this machine, judged by the socket rather than by anything the caller can set.
|
|
32
|
+
*
|
|
33
|
+
* It identifies itself as a native component. Loopback alone is not enough, because a web page can reach
|
|
34
|
+
* 127.0.0.1 and a browser fingerprint is whatever the other end says it is. A local process running as this user
|
|
35
|
+
* could already read the same files directly, so granting it access adds no exposure; a browser is not in that
|
|
36
|
+
* position, and still needs a person.
|
|
37
|
+
*
|
|
38
|
+
* Nothing relayed the request. Otherwise Facet's proxy would extend its own locality to every caller behind it.
|
|
39
|
+
*/
|
|
40
|
+
export declare function qualifiesForAutoApproval(kind: 'native' | 'browser', peerAddress: string | undefined, headers: Readonly<Record<string, unknown>>): boolean;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may be approved without a person deciding.
|
|
3
|
+
*
|
|
4
|
+
* Separate from the store that applies it so the rule can be read and tested on its own. It governs access to
|
|
5
|
+
* Sessions, Providers and every file Capability Core exposes, which is not a decision to leave implicit in the
|
|
6
|
+
* middle of a request handler.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Whether a socket address is on this machine.
|
|
10
|
+
*
|
|
11
|
+
* Must be given the raw socket address. A forwarded header is supplied by the caller, so deriving locality from
|
|
12
|
+
* one would let anyone claim it.
|
|
13
|
+
*/
|
|
14
|
+
export function loopbackAddress(address) {
|
|
15
|
+
// Absent for a non-IP socket. Refusing is the safe answer: the caller cannot be shown to be local.
|
|
16
|
+
if (address === undefined)
|
|
17
|
+
return false;
|
|
18
|
+
// A dual-stack listener reports an IPv4 peer as `::ffff:127.0.0.1`.
|
|
19
|
+
const normalized = (address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address).toLowerCase();
|
|
20
|
+
if (normalized === '::1')
|
|
21
|
+
return true;
|
|
22
|
+
// Every octet checked rather than just the `127.` prefix, so nothing that merely begins with it can pass. Node
|
|
23
|
+
// reports addresses and not names, so `127.0.0.1.example.com` cannot arrive here -- but this decides who
|
|
24
|
+
// reaches the API, and a rule that would accept such a string if it ever could is not one to keep.
|
|
25
|
+
const octets = normalized.split('.');
|
|
26
|
+
if (octets.length !== 4)
|
|
27
|
+
return false;
|
|
28
|
+
if (!octets.every((octet) => /^\d{1,3}$/u.test(octet) && Number(octet) <= 255))
|
|
29
|
+
return false;
|
|
30
|
+
return octets[0] === '127';
|
|
31
|
+
}
|
|
32
|
+
/** Headers that mean something forwarded this request rather than originating it. */
|
|
33
|
+
const RELAY_HEADERS = ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'forwarded', 'via'];
|
|
34
|
+
/**
|
|
35
|
+
* Whether the request reached Core through something else.
|
|
36
|
+
*
|
|
37
|
+
* This is what stops a relay from lending its own locality to whoever is behind it. Facet's host proxies browser
|
|
38
|
+
* traffic to Core from loopback, so without this every caller that can reach Facet -- from any machine -- would
|
|
39
|
+
* look local to Core and be approved automatically.
|
|
40
|
+
*
|
|
41
|
+
* Presence is the whole test, and it does not matter whether the header can be trusted. A relay that sets it
|
|
42
|
+
* honestly is excluded, and a caller that forges one only manages to exclude itself.
|
|
43
|
+
*/
|
|
44
|
+
export function isRelayed(headers) {
|
|
45
|
+
return RELAY_HEADERS.some((name) => {
|
|
46
|
+
const value = headers[name];
|
|
47
|
+
if (typeof value === 'string')
|
|
48
|
+
return value.trim() !== '';
|
|
49
|
+
return Array.isArray(value) && value.length > 0;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Whether an unknown caller may be approved on first contact.
|
|
54
|
+
*
|
|
55
|
+
* Three conditions, each earning its place.
|
|
56
|
+
*
|
|
57
|
+
* The connection comes from this machine, judged by the socket rather than by anything the caller can set.
|
|
58
|
+
*
|
|
59
|
+
* It identifies itself as a native component. Loopback alone is not enough, because a web page can reach
|
|
60
|
+
* 127.0.0.1 and a browser fingerprint is whatever the other end says it is. A local process running as this user
|
|
61
|
+
* could already read the same files directly, so granting it access adds no exposure; a browser is not in that
|
|
62
|
+
* position, and still needs a person.
|
|
63
|
+
*
|
|
64
|
+
* Nothing relayed the request. Otherwise Facet's proxy would extend its own locality to every caller behind it.
|
|
65
|
+
*/
|
|
66
|
+
export function qualifiesForAutoApproval(kind, peerAddress, headers) {
|
|
67
|
+
return kind === 'native' && loopbackAddress(peerAddress) && !isRelayed(headers);
|
|
68
|
+
}
|
package/dist/core/access.d.ts
CHANGED
|
@@ -15,10 +15,38 @@ export declare class PairStore {
|
|
|
15
15
|
get metadataRevision(): number;
|
|
16
16
|
get pairDigest(): string;
|
|
17
17
|
load(): Promise<void>;
|
|
18
|
-
start(onError?: (error: Error) => void): void;
|
|
18
|
+
start(onError?: (error: Error) => void, onReload?: () => void): void;
|
|
19
19
|
close(): void;
|
|
20
20
|
identify(headers: Headers): Identity;
|
|
21
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Decides whether a caller may proceed.
|
|
23
|
+
*
|
|
24
|
+
* A component running on this machine is admitted without anyone pairing it by hand. Judged on three things,
|
|
25
|
+
* each doing work: the connection arrives on loopback, the caller identifies itself as native, and nothing
|
|
26
|
+
* relayed the request. What the pair file can defend is the reasoning -- a local process running as this user
|
|
27
|
+
* could read the same files and run the same tools directly, so refusing it protects nothing, while a caller
|
|
28
|
+
* from another machine and a browser still require a person.
|
|
29
|
+
*
|
|
30
|
+
* A browser is excluded however local it is, because a page can reach 127.0.0.1 and a fingerprint is whatever
|
|
31
|
+
* the other end claims. Relayed requests are excluded because Facet proxies browser traffic from loopback, so
|
|
32
|
+
* without that condition Facet would lend its locality to every caller behind it.
|
|
33
|
+
*
|
|
34
|
+
* `strongTrust` is deliberately not granted this way. Raw configuration holds Provider credentials, and
|
|
35
|
+
* reaching it still requires a credential somebody issued on purpose, so the exemption buys ordinary API
|
|
36
|
+
* access and nothing more.
|
|
37
|
+
*
|
|
38
|
+
* `peerAddress` must be the raw socket address. A forwarded header is supplied by the caller, so locality
|
|
39
|
+
* derived from one would be locality anyone could claim.
|
|
40
|
+
*/
|
|
41
|
+
authorize(headers: Headers, transport: 'http' | 'https', correlationId: string, registerUnknown?: boolean, peerAddress?: string): Promise<TrustedIngress>;
|
|
42
|
+
requireStrong(headers: Headers): Promise<void>;
|
|
43
|
+
document(): Promise<{
|
|
44
|
+
content: string;
|
|
45
|
+
file_digest: string;
|
|
46
|
+
}>;
|
|
47
|
+
replace(source: string, expectedDigest: string, headers: Headers): Promise<void>;
|
|
48
|
+
/** Local administration only: the credential is returned once, never stored in plaintext. */
|
|
49
|
+
issueCredential(clientId: string, strongTrust: boolean): Promise<string>;
|
|
22
50
|
reload(): Promise<void>;
|
|
23
51
|
}
|
|
24
52
|
export {};
|
package/dist/core/access.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
1
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import { watch } from 'node:fs';
|
|
3
|
-
import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { basename, dirname, resolve } from 'node:path';
|
|
5
5
|
import { digest, now } from './codec.js';
|
|
6
6
|
import { fail } from './domain.js';
|
|
7
|
+
import { qualifiesForAutoApproval } from './access-policy.js';
|
|
7
8
|
export class PairStore {
|
|
8
9
|
onRevoked;
|
|
9
10
|
path;
|
|
@@ -32,11 +33,11 @@ export class PairStore {
|
|
|
32
33
|
}
|
|
33
34
|
await this.reload();
|
|
34
35
|
}
|
|
35
|
-
start(onError) {
|
|
36
|
+
start(onError, onReload) {
|
|
36
37
|
this.#watcher = watch(dirname(this.path), (_event, filename) => {
|
|
37
38
|
if (filename !== null && filename !== basename(this.path))
|
|
38
39
|
return;
|
|
39
|
-
this.#writeTail = this.#writeTail.then(() => this.reload()).catch((error) => {
|
|
40
|
+
this.#writeTail = this.#writeTail.then(async () => { await this.reload(); onReload?.(); }).catch((error) => {
|
|
40
41
|
this.#metadataRevision += 1;
|
|
41
42
|
onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
42
43
|
});
|
|
@@ -58,28 +59,139 @@ export class PairStore {
|
|
|
58
59
|
}
|
|
59
60
|
fail('CLIENT_ID_INVALID', 400, 'X-Lens-Client-Kind must be native or browser.');
|
|
60
61
|
}
|
|
61
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Decides whether a caller may proceed.
|
|
64
|
+
*
|
|
65
|
+
* A component running on this machine is admitted without anyone pairing it by hand. Judged on three things,
|
|
66
|
+
* each doing work: the connection arrives on loopback, the caller identifies itself as native, and nothing
|
|
67
|
+
* relayed the request. What the pair file can defend is the reasoning -- a local process running as this user
|
|
68
|
+
* could read the same files and run the same tools directly, so refusing it protects nothing, while a caller
|
|
69
|
+
* from another machine and a browser still require a person.
|
|
70
|
+
*
|
|
71
|
+
* A browser is excluded however local it is, because a page can reach 127.0.0.1 and a fingerprint is whatever
|
|
72
|
+
* the other end claims. Relayed requests are excluded because Facet proxies browser traffic from loopback, so
|
|
73
|
+
* without that condition Facet would lend its locality to every caller behind it.
|
|
74
|
+
*
|
|
75
|
+
* `strongTrust` is deliberately not granted this way. Raw configuration holds Provider credentials, and
|
|
76
|
+
* reaching it still requires a credential somebody issued on purpose, so the exemption buys ordinary API
|
|
77
|
+
* access and nothing more.
|
|
78
|
+
*
|
|
79
|
+
* `peerAddress` must be the raw socket address. A forwarded header is supplied by the caller, so locality
|
|
80
|
+
* derived from one would be locality anyone could claim.
|
|
81
|
+
*/
|
|
82
|
+
async authorize(headers, transport, correlationId, registerUnknown = true, peerAddress) {
|
|
83
|
+
await this.reload();
|
|
62
84
|
const client = this.identify(headers);
|
|
63
|
-
|
|
85
|
+
// Evaluated per request, never stored. A record admitted locally must not let the same client in later from
|
|
86
|
+
// a browser, or through a proxy, without a credential.
|
|
87
|
+
const local = qualifiesForAutoApproval(client.kind, peerAddress, headers);
|
|
88
|
+
let record = this.#records.get(client.clientId);
|
|
89
|
+
if (record === undefined && local && registerUnknown) {
|
|
90
|
+
await this.#appendUnknown(client, true);
|
|
91
|
+
record = this.#records.get(client.clientId);
|
|
92
|
+
}
|
|
64
93
|
if (record?.isVerified !== true) {
|
|
65
|
-
if (record === undefined)
|
|
66
|
-
await this.#appendUnknown(client);
|
|
94
|
+
if (record === undefined && registerUnknown)
|
|
95
|
+
await this.#appendUnknown(client, false);
|
|
67
96
|
fail('CLIENT_UNVERIFIED', 403, 'The client has not been verified.', { client_id: client.clientId });
|
|
68
97
|
}
|
|
69
|
-
|
|
98
|
+
const authorization = headers.authorization;
|
|
99
|
+
const token = typeof authorization === 'string' && authorization.startsWith('Bearer ') ? authorization.slice(7) : '';
|
|
100
|
+
const credentialValid = record.credentialHash !== null
|
|
101
|
+
&& /^[A-Za-z0-9_-]{43}$/.test(token)
|
|
102
|
+
&& timingSafeEqual(Buffer.from(record.credentialHash, 'hex'), Buffer.from(tokenHash(token), 'hex'));
|
|
103
|
+
if (!credentialValid && !local) {
|
|
104
|
+
fail('CLIENT_CREDENTIAL_INVALID', 401, 'A valid Pair credential is required.');
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
source: 'user',
|
|
108
|
+
clientId: client.clientId,
|
|
109
|
+
transport,
|
|
110
|
+
correlationId,
|
|
111
|
+
// Strong trust follows the credential, not the network. A client granted it keeps it only while presenting
|
|
112
|
+
// the credential it was granted with.
|
|
113
|
+
strongTrust: record.strongTrust && credentialValid
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
async requireStrong(headers) {
|
|
117
|
+
// Called inside the write queue too; never enqueue registration behind that same write.
|
|
118
|
+
const ingress = await this.authorize(headers, 'http', 'configuration', false);
|
|
119
|
+
if (!ingress.strongTrust)
|
|
120
|
+
fail('STRONG_TRUST_REQUIRED', 403, 'This operation requires a strongly trusted client.');
|
|
121
|
+
}
|
|
122
|
+
async document() {
|
|
123
|
+
const content = await readFile(this.path, 'utf8');
|
|
124
|
+
return { content, file_digest: tokenHash(content) };
|
|
125
|
+
}
|
|
126
|
+
replace(source, expectedDigest, headers) {
|
|
127
|
+
const operation = this.#writeTail.then(async () => {
|
|
128
|
+
const check = async () => {
|
|
129
|
+
await this.requireStrong(headers);
|
|
130
|
+
const current = await this.document();
|
|
131
|
+
if (current.file_digest !== expectedDigest)
|
|
132
|
+
fail('CONFIG_CONFLICT', 409, 'The pair file changed.');
|
|
133
|
+
};
|
|
134
|
+
await check();
|
|
135
|
+
parsePair(source);
|
|
136
|
+
await this.#save(source, check);
|
|
137
|
+
});
|
|
138
|
+
this.#writeTail = operation.catch(() => undefined);
|
|
139
|
+
return operation;
|
|
140
|
+
}
|
|
141
|
+
/** Local administration only: the credential is returned once, never stored in plaintext. */
|
|
142
|
+
issueCredential(clientId, strongTrust) {
|
|
143
|
+
const operation = this.#writeTail.then(async () => {
|
|
144
|
+
const records = await readPairFile(this.path);
|
|
145
|
+
const index = records.findIndex(record => record.client_id === clientId);
|
|
146
|
+
const current = records[index];
|
|
147
|
+
if (current === undefined)
|
|
148
|
+
fail('CLIENT_NOT_FOUND', 404, 'Connect the client first to register its identity.');
|
|
149
|
+
const token = randomBytes(32).toString('base64url');
|
|
150
|
+
records[index] = { ...current, isVerified: true, strongTrust, credentialHash: tokenHash(token) };
|
|
151
|
+
await this.#save(`${JSON.stringify({ clients: records }, null, 2)}\n`);
|
|
152
|
+
return token;
|
|
153
|
+
});
|
|
154
|
+
this.#writeTail = operation.then(() => undefined, () => undefined);
|
|
155
|
+
return operation;
|
|
156
|
+
}
|
|
157
|
+
async #save(source, beforeCommit) {
|
|
158
|
+
const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
|
|
159
|
+
try {
|
|
160
|
+
await writeFile(temporary, source, { encoding: 'utf8', mode: 0o600 });
|
|
161
|
+
await beforeCommit?.();
|
|
162
|
+
await rename(temporary, this.path);
|
|
163
|
+
await this.reload();
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
await rm(temporary, { force: true });
|
|
167
|
+
}
|
|
70
168
|
}
|
|
71
169
|
async reload() {
|
|
72
|
-
|
|
170
|
+
let next;
|
|
171
|
+
try {
|
|
172
|
+
next = await readPairFile(this.path);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
for (const clientId of this.#records.keys())
|
|
176
|
+
this.onRevoked(clientId);
|
|
177
|
+
this.#records.clear();
|
|
178
|
+
this.#digest = '';
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
const nextDigest = tokenHash(JSON.stringify(next));
|
|
182
|
+
if (nextDigest === this.#digest)
|
|
183
|
+
return;
|
|
73
184
|
const previous = this.#records;
|
|
74
185
|
this.#records = new Map(next.map((entry) => [entry.client_id, entry]));
|
|
75
|
-
this.#digest =
|
|
186
|
+
this.#digest = nextDigest;
|
|
76
187
|
this.#metadataRevision += 1;
|
|
77
188
|
for (const [clientId, record] of previous) {
|
|
78
|
-
|
|
189
|
+
const current = this.#records.get(clientId);
|
|
190
|
+
if (record.isVerified && (current?.isVerified !== true || current.credentialHash !== record.credentialHash || current.strongTrust !== record.strongTrust))
|
|
79
191
|
this.onRevoked(clientId);
|
|
80
192
|
}
|
|
81
193
|
}
|
|
82
|
-
#appendUnknown(identityValue) {
|
|
194
|
+
#appendUnknown(identityValue, verified) {
|
|
83
195
|
const write = this.#writeTail.then(async () => {
|
|
84
196
|
const records = await readPairFile(this.path);
|
|
85
197
|
if (records.some((entry) => entry.client_id === identityValue.clientId)) {
|
|
@@ -95,8 +207,12 @@ export class PairStore {
|
|
|
95
207
|
mac: identityValue.mac,
|
|
96
208
|
hardware_id: identityValue.hardwareId,
|
|
97
209
|
browser_fingerprint: identityValue.browserFingerprint,
|
|
98
|
-
isVerified:
|
|
99
|
-
|
|
210
|
+
isVerified: verified,
|
|
211
|
+
// Never strongly trusted and never given a credential by this path: an automatic approval grants
|
|
212
|
+
// ordinary API access, and anything more stays a deliberate act.
|
|
213
|
+
strongTrust: false,
|
|
214
|
+
credentialHash: null,
|
|
215
|
+
label: verified ? 'Approved automatically: local native client' : null,
|
|
100
216
|
first_seen_at: timestamp,
|
|
101
217
|
last_seen_at: timestamp
|
|
102
218
|
});
|
|
@@ -104,7 +220,7 @@ export class PairStore {
|
|
|
104
220
|
if (Buffer.byteLength(source) > 1_048_576)
|
|
105
221
|
fail('PAIR_FILE_CAPACITY', 503, 'The pair file reached its size limit.');
|
|
106
222
|
const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
|
|
107
|
-
await writeFile(temporary, source, 'utf8');
|
|
223
|
+
await writeFile(temporary, source, { encoding: 'utf8', mode: 0o600 });
|
|
108
224
|
await rename(temporary, this.path);
|
|
109
225
|
await this.reload();
|
|
110
226
|
});
|
|
@@ -113,29 +229,44 @@ export class PairStore {
|
|
|
113
229
|
}
|
|
114
230
|
}
|
|
115
231
|
async function readPairFile(path) {
|
|
232
|
+
let source;
|
|
233
|
+
try {
|
|
234
|
+
source = await readFile(path, 'utf8');
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
fail('PAIR_FILE_INVALID', 503, 'The pair file could not be loaded.');
|
|
238
|
+
}
|
|
239
|
+
return parsePair(source);
|
|
240
|
+
}
|
|
241
|
+
function parsePair(source) {
|
|
242
|
+
if (Buffer.byteLength(source) > 1_048_576)
|
|
243
|
+
fail('PAIR_FILE_CAPACITY', 503, 'The pair file reached its size limit.');
|
|
116
244
|
let document;
|
|
117
245
|
try {
|
|
118
|
-
const source = await readFile(path, 'utf8');
|
|
119
|
-
if (Buffer.byteLength(source) > 1_048_576)
|
|
120
|
-
fail('PAIR_FILE_CAPACITY', 503, 'The pair file reached its size limit.');
|
|
121
246
|
document = JSON.parse(source);
|
|
122
247
|
}
|
|
123
|
-
catch
|
|
124
|
-
|
|
125
|
-
throw error;
|
|
126
|
-
fail('PAIR_FILE_INVALID', 503, 'The pair file could not be loaded.');
|
|
248
|
+
catch {
|
|
249
|
+
fail('PAIR_FILE_INVALID', 400, 'The pair file is not valid JSON.');
|
|
127
250
|
}
|
|
128
251
|
if (typeof document !== 'object' || document === null || Array.isArray(document))
|
|
129
|
-
fail('PAIR_FILE_INVALID',
|
|
252
|
+
fail('PAIR_FILE_INVALID', 400, 'The pair file is invalid.');
|
|
130
253
|
const clients = document.clients;
|
|
131
254
|
if (!Array.isArray(clients) || clients.length > 4096)
|
|
132
|
-
fail('PAIR_FILE_INVALID',
|
|
133
|
-
|
|
255
|
+
fail('PAIR_FILE_INVALID', 400, 'The pair file is invalid.');
|
|
256
|
+
const records = clients.map(pairRecord);
|
|
257
|
+
if (new Set(records.map(record => record.client_id)).size !== records.length)
|
|
258
|
+
fail('PAIR_FILE_INVALID', 400, 'Duplicate client identities.');
|
|
259
|
+
return records;
|
|
134
260
|
}
|
|
261
|
+
function tokenHash(value) { return createHash('sha256').update(value).digest('hex'); }
|
|
135
262
|
function pairRecord(value) {
|
|
136
263
|
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
137
264
|
fail('PAIR_FILE_INVALID', 503, 'A pair record is invalid.');
|
|
138
265
|
const row = value;
|
|
266
|
+
if (row.strongTrust !== undefined && typeof row.strongTrust !== 'boolean')
|
|
267
|
+
fail('PAIR_FILE_INVALID', 400, 'strongTrust must be a boolean.');
|
|
268
|
+
if (row.credentialHash !== undefined && row.credentialHash !== null && (typeof row.credentialHash !== 'string' || !/^[a-f0-9]{64}$/.test(row.credentialHash)))
|
|
269
|
+
fail('PAIR_FILE_INVALID', 400, 'Invalid credential digest.');
|
|
139
270
|
const kind = row.client_kind;
|
|
140
271
|
if (kind !== 'native' && kind !== 'browser')
|
|
141
272
|
fail('PAIR_FILE_INVALID', 503, 'A pair client kind is invalid.');
|
|
@@ -147,6 +278,8 @@ function pairRecord(value) {
|
|
|
147
278
|
hardware_id: nullableString(row.hardware_id),
|
|
148
279
|
browser_fingerprint: nullableString(row.browser_fingerprint),
|
|
149
280
|
isVerified: row.isVerified === true,
|
|
281
|
+
strongTrust: row.strongTrust === true,
|
|
282
|
+
credentialHash: typeof row.credentialHash === 'string' ? row.credentialHash : null,
|
|
150
283
|
label,
|
|
151
284
|
first_seen_at: string(row.first_seen_at, 'first_seen_at'),
|
|
152
285
|
last_seen_at: string(row.last_seen_at, 'last_seen_at')
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type ModelMessage } from './domain.js';
|
|
2
|
+
import type { Document } from '../store/store.js';
|
|
3
|
+
export interface ContextBoundaries {
|
|
4
|
+
readonly legalCuts: readonly number[];
|
|
5
|
+
readonly protectedRanges: readonly {
|
|
6
|
+
start: number;
|
|
7
|
+
end: number;
|
|
8
|
+
complete: boolean;
|
|
9
|
+
}[];
|
|
10
|
+
readonly violations: readonly {
|
|
11
|
+
index: number;
|
|
12
|
+
reason: string;
|
|
13
|
+
}[];
|
|
14
|
+
}
|
|
15
|
+
/** Cuts are positions between messages. IDs are scoped to a batch, not the session. */
|
|
16
|
+
export declare function contextBoundaries(messages: readonly ModelMessage[], unsettledItemIds?: readonly string[]): ContextBoundaries;
|
|
17
|
+
export declare function assertContextBoundaries(messages: readonly ModelMessage[], options?: {
|
|
18
|
+
allowOpenTail?: boolean;
|
|
19
|
+
unsettledItemIds?: readonly string[];
|
|
20
|
+
}): ContextBoundaries;
|
|
21
|
+
/** Preserve source membership, even if two different batches reuse the same wire IDs. */
|
|
22
|
+
export declare function assertContextTransition(before: readonly ModelMessage[], after: readonly ModelMessage[], unsettledItemIds: readonly string[]): void;
|
|
23
|
+
export declare function contextToolCalls(content: string): NonNullable<ModelMessage['toolCalls']>;
|
|
24
|
+
/** Included results without native tool identity are evidence, not protocol responses. */
|
|
25
|
+
export declare function boundaryMessages(items: readonly Document[]): readonly ModelMessage[];
|