@lensagent/core 0.1.1 → 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.
Files changed (99) hide show
  1. package/README.md +63 -4
  2. package/config/lens.example.jsonc +36 -13
  3. package/dist/adapters/executor-router.d.ts +20 -0
  4. package/dist/adapters/executor-router.js +86 -0
  5. package/dist/adapters/executor.d.ts +9 -3
  6. package/dist/adapters/executor.js +196 -61
  7. package/dist/adapters/lsp.d.ts +2 -1
  8. package/dist/adapters/lsp.js +98 -41
  9. package/dist/adapters/mcp.js +52 -17
  10. package/dist/adapters/revert.d.ts +5 -4
  11. package/dist/adapters/revert.js +6 -64
  12. package/dist/adapters/web.d.ts +9 -0
  13. package/dist/adapters/web.js +244 -0
  14. package/dist/config/files.d.ts +14 -0
  15. package/dist/config/files.js +57 -0
  16. package/dist/config/runtime.d.ts +3 -4
  17. package/dist/config/runtime.js +116 -97
  18. package/dist/config/schema.d.ts +56 -10
  19. package/dist/config/schema.js +48 -8
  20. package/dist/contracts/capability.d.ts +27 -1
  21. package/dist/contracts/domain.d.ts +1 -0
  22. package/dist/contracts/model.d.ts +33 -6
  23. package/dist/contracts/review.d.ts +17 -0
  24. package/dist/contracts/review.js +1 -0
  25. package/dist/core/access-policy.d.ts +40 -0
  26. package/dist/core/access-policy.js +68 -0
  27. package/dist/core/access.d.ts +30 -2
  28. package/dist/core/access.js +159 -26
  29. package/dist/core/context-boundaries.d.ts +25 -0
  30. package/dist/core/context-boundaries.js +106 -0
  31. package/dist/core/context.d.ts +3 -0
  32. package/dist/core/context.js +11 -0
  33. package/dist/core/host.js +261 -173
  34. package/dist/entry/pair.d.ts +1 -0
  35. package/dist/entry/pair.js +14 -0
  36. package/dist/entry/run.js +1 -1
  37. package/dist/execution/lsp-process.d.ts +3 -2
  38. package/dist/execution/lsp-process.js +46 -28
  39. package/dist/http/server.d.ts +5 -0
  40. package/dist/http/server.js +262 -80
  41. package/dist/runtime/agent.d.ts +9 -4
  42. package/dist/runtime/agent.js +361 -286
  43. package/dist/runtime/artifacts.d.ts +13 -1
  44. package/dist/runtime/artifacts.js +137 -10
  45. package/dist/runtime/cache.d.ts +59 -16
  46. package/dist/runtime/cache.js +124 -43
  47. package/dist/runtime/calls.d.ts +1 -1
  48. package/dist/runtime/calls.js +164 -48
  49. package/dist/runtime/capabilities.d.ts +2 -2
  50. package/dist/runtime/capabilities.js +4 -5
  51. package/dist/runtime/cleanup.d.ts +5 -2
  52. package/dist/runtime/cleanup.js +48 -17
  53. package/dist/runtime/compaction.d.ts +9 -0
  54. package/dist/runtime/compaction.js +52 -0
  55. package/dist/runtime/context.d.ts +8 -4
  56. package/dist/runtime/context.js +115 -128
  57. package/dist/runtime/events.d.ts +7 -0
  58. package/dist/runtime/events.js +83 -0
  59. package/dist/runtime/images.d.ts +14 -0
  60. package/dist/runtime/images.js +82 -0
  61. package/dist/runtime/kernel-capabilities.d.ts +7 -1
  62. package/dist/runtime/kernel-capabilities.js +146 -68
  63. package/dist/runtime/model-audit.d.ts +9 -0
  64. package/dist/runtime/model-audit.js +39 -0
  65. package/dist/runtime/model.d.ts +109 -4
  66. package/dist/runtime/model.js +368 -143
  67. package/dist/runtime/observability.d.ts +1 -0
  68. package/dist/runtime/observability.js +60 -15
  69. package/dist/runtime/policy.d.ts +1 -1
  70. package/dist/runtime/policy.js +2 -2
  71. package/dist/runtime/profile.d.ts +2 -1
  72. package/dist/runtime/profile.js +12 -15
  73. package/dist/runtime/projects.d.ts +3 -1
  74. package/dist/runtime/projects.js +38 -17
  75. package/dist/runtime/ptc-worker.d.ts +1 -0
  76. package/dist/runtime/ptc-worker.js +97 -0
  77. package/dist/runtime/ptc.d.ts +16 -0
  78. package/dist/runtime/ptc.js +128 -0
  79. package/dist/runtime/results.d.ts +5 -4
  80. package/dist/runtime/results.js +29 -51
  81. package/dist/runtime/review.d.ts +4 -1
  82. package/dist/runtime/review.js +101 -22
  83. package/dist/runtime/scheduler.js +1 -0
  84. package/dist/runtime/skills.js +34 -5
  85. package/dist/runtime/task-audit.d.ts +21 -0
  86. package/dist/runtime/task-audit.js +122 -0
  87. package/dist/runtime/time.d.ts +9 -0
  88. package/dist/runtime/time.js +18 -0
  89. package/dist/runtime/tokenizer.d.ts +2 -0
  90. package/dist/runtime/tokenizer.js +26 -1
  91. package/dist/store/database.js +26 -1
  92. package/dist/store/event-log.d.ts +1 -0
  93. package/dist/store/event-log.js +22 -5
  94. package/dist/store/schema.d.ts +1 -1
  95. package/dist/store/schema.js +50 -1
  96. package/dist/store/store.d.ts +65 -19
  97. package/dist/store/store.js +986 -188
  98. package/package.json +14 -6
  99. 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' | 'command' | 'skill';
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
+ }
@@ -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
- authorize(headers: Headers, transport: 'http' | 'https', correlationId: string): Promise<TrustedIngress>;
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 {};
@@ -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
- async authorize(headers, transport, correlationId) {
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
- const record = this.#records.get(client.clientId);
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
- return { source: 'user', clientId: client.clientId, transport, correlationId };
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
- const next = await readPairFile(this.path);
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 = digest(next.filter((entry) => entry.isVerified).map((entry) => entry.client_id).sort());
186
+ this.#digest = nextDigest;
76
187
  this.#metadataRevision += 1;
77
188
  for (const [clientId, record] of previous) {
78
- if (record.isVerified && this.#records.get(clientId)?.isVerified !== true)
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: false,
99
- label: null,
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 (error) {
124
- if (error instanceof Error && error.name === 'LensError')
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', 503, 'The pair file is 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', 503, 'The pair file is invalid.');
133
- return clients.map(pairRecord);
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[];