@entrorove/skill 0.1.32 → 0.1.34

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/remote-client.mjs CHANGED
@@ -2,6 +2,8 @@ import { createHash } from 'node:crypto';
2
2
  import Ajv from 'ajv';
3
3
  import { parseTransportResponse, requestTransport, trustedOrigin } from './remote-transport.mjs';
4
4
  import { appendInvocation } from './local-invocations.mjs';
5
+ import { verifyAutomaticFeedback } from './automatic-feedback-client.mjs';
6
+ import { ideClientHeaders, presentInvocationNotices } from './ide-presentation.mjs';
5
7
 
6
8
  export const apiUrl = 'https://entrorove.com/api/skill/invoke';
7
9
 
@@ -33,11 +35,17 @@ export function buildInvocation(operation, input) {
33
35
  if (!['capabilities', 'task-describe', 'message-route', 'message-accept', 'task-resume', 'task-checkpoint',
34
36
  'message-status', 'message-delivery-start', 'message-delivery-report', 'message-resolve', 'message-complete',
35
37
  'handoff-prepare', 'handoff-release', 'handoff-claim', 'handoff-status', 'handoff-resume', 'handoff-execution-authority', 'mutation-snapshot-challenge', 'mutation-pass-issue',
38
+ 'scope-runner-prepare', 'scope-runner-register', 'scope-runner-challenge', 'scope-runner-attest',
36
39
  'start', 'continue'].includes(operation)) throw new Error('Invalid entrorove operation');
37
40
  if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Input must be a JSON object');
38
41
  const id = requestId(operation, input);
39
42
  return { url: apiUrl, requestId: id, body: { schemaVersion: 'entrorove.skill.request/2.0', requestId: id, operation, input } };
40
43
  }
44
+ function requiresAutomaticFeedback(operation, input, capabilities) {
45
+ const actual = operation === 'continue' ? input.operation : operation;
46
+ return capabilities?.responseExtensions?.automaticFeedback === 'entrorove.automatic-feedback/1.0'
47
+ && !capabilities.responseExtensions.automaticFeedbackObservations?.includes(actual);
48
+ }
41
49
 
42
50
  export function validateDiscoveredChecks(input, checks = []) {
43
51
  for (const check of checks) {
@@ -62,25 +70,35 @@ export async function invokeRemote(data, dependencies) {
62
70
  process.exitCode = 2;
63
71
  return;
64
72
  }
65
- const call = { ...buildInvocation(data.operation, data.input), url: `${trustedOrigin(apiOrigin())}/api/skill/invoke` };
66
73
  const token = await readCredential(home);
74
+ const { response, result } = await invokeDiscovered(data, token, { home, apiOrigin });
75
+ process.stdout.write(JSON.stringify(presentInvocationNotices(result,dependencies.onIdeEvent)) + '\n');
76
+ if (!response.ok || result.output.status === 'blocked' || result.output.status === 'failed'
77
+ || result.output.status === 'incomplete') process.exitCode = 2;
78
+ }
79
+
80
+ export async function invokeRemoteResult(data, dependencies) {
81
+ const token = await machineCredential(dependencies);
82
+ return invokeDiscovered(data, token, dependencies);
83
+ }
84
+ async function invokeDiscovered(data, token, { home, apiOrigin }) {
85
+ const call = { ...buildInvocation(data.operation, data.input), url: `${trustedOrigin(apiOrigin())}/api/skill/invoke` };
86
+ let feedbackRequired = false;
67
87
  if (data.operation !== 'capabilities') {
68
88
  const discovery = await postInvocation({ ...buildInvocation('capabilities', {}), url: call.url }, token, home);
69
89
  if (!discovery.response.ok || discovery.result.output.status !== 'succeeded') throw new Error(`Capability discovery failed: HTTP ${discovery.response.status}`);
70
90
  const capabilities = discovery.result.output.capabilities;
91
+ feedbackRequired = requiresAutomaticFeedback(data.operation,data.input,capabilities);
71
92
  const schema = capabilities?.operationSchemas?.[data.operation];
72
93
  if (!capabilities?.operations?.includes(data.operation) || !schema) throw new Error(`Capability discovery does not support ${data.operation}`);
73
94
  const validate = discoveredAjv().compile(schema);
74
95
  if (!validate(data.input)) throw new Error(`Invalid ${data.operation} input: ${validate.errors.map(error => `${error.instancePath || '/'} ${error.message}`).join('; ')}`);
75
96
  validateDiscoveredChecks(data.input, capabilities.inputChecks?.[data.operation]);
76
97
  }
77
- const { response, result } = await postInvocation(call, token, home);
78
- process.stdout.write(JSON.stringify(result) + '\n');
79
- if (!response.ok || result.output.status === 'blocked' || result.output.status === 'failed'
80
- || result.output.status === 'incomplete') process.exitCode = 2;
98
+ return postInvocation(call, token, home,feedbackRequired);
81
99
  }
82
100
 
83
- async function postInvocation(call, token, home) {
101
+ async function postInvocation(call, token, home, feedbackRequired = false) {
84
102
  const metadata = { kind: 'remote', operation: call.body.operation, requestId: call.requestId };
85
103
  const logged = async value => {
86
104
  try { return await appendInvocation({ ...metadata, ...value }, home); }
@@ -91,7 +109,7 @@ async function postInvocation(call, token, home) {
91
109
  try {
92
110
  response = await requestTransport(new URL(call.url).origin, '/api/skill/invoke', {
93
111
  method: 'POST', timeoutMs: 135_000,
94
- headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify(call.body),
112
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}`,...ideClientHeaders() }, body: JSON.stringify(call.body),
95
113
  });
96
114
  } catch (cause) {
97
115
  if (cause.established) await logged({ event: 'outcome-unknown' });
@@ -113,6 +131,10 @@ async function postInvocation(call, token, home) {
113
131
  await logged({ event: 'outcome-unknown' }); throw unknownInvocation(call.requestId);
114
132
  }
115
133
  await logged({ event: response.ok ? 'completed' : 'rejected', usage: result.usage ?? [] });
134
+ if (result.feedback) verifyAutomaticFeedback(result.feedback,call.requestId,call.requestId);
135
+ else if (feedbackRequired && !['running','pending'].includes(result.output.status)) {
136
+ throw new Error(`Automatic feedback receipt is missing; query feedback ${call.requestId}. Do not repeat the invocation.`);
137
+ }
116
138
  return { response, result };
117
139
  }
118
140
 
@@ -149,13 +171,16 @@ export function createTaskMessageClient(dependencies) {
149
171
  async invoke(operation, input) {
150
172
  const original = buildInvocation(operation, input);
151
173
  const token = await machineCredential(dependencies); const origin = machineOrigin(dependencies);
174
+ let feedbackRequired = false;
152
175
  if (operation !== 'capabilities') {
153
176
  const discovery = buildInvocation('capabilities', {});
154
177
  const discovered = await postInvocation({ ...discovery, url: `${origin}/api/skill/invoke` }, token, dependencies.home);
155
178
  if (!discovered.response.ok || discovered.result.output.status !== 'succeeded') throw new Error('Task message capability discovery failed');
156
179
  validateMachineCapability(operation, input, discovered.result.output.capabilities);
180
+ feedbackRequired = requiresAutomaticFeedback(operation,input,discovered.result.output.capabilities);
157
181
  }
158
- const { response, result } = await postInvocation({ ...original, url: `${origin}/api/skill/invoke` }, token, dependencies.home);
182
+ const { response, result } = await postInvocation({ ...original, url: `${origin}/api/skill/invoke` }, token, dependencies.home,feedbackRequired);
183
+ presentInvocationNotices(result,dependencies.onIdeEvent);
159
184
  if (stoppedOriginalResume(response, result.output, operation, input)) return result.output;
160
185
  if (!response.ok || ['blocked', 'failed', 'incomplete'].includes(result.output.status)) {
161
186
  throw new Error(`Task message invocation was rejected: HTTP ${response.status}; requestId=${original.requestId}`);
@@ -169,6 +194,8 @@ export function createTaskMessageClient(dependencies) {
169
194
  { authorization: `Bearer ${token}` }, 'original task message status');
170
195
  const result = await readResponse(response, 'task message status');
171
196
  if (!response.ok || !result?.output || result.output.requestId !== id) throw new Error('Original task message status is unavailable or invalid');
197
+ if (result.feedback) verifyAutomaticFeedback(result.feedback,id);
198
+ presentInvocationNotices(result,dependencies.onIdeEvent);
172
199
  await appendInvocation({ kind: 'remote', operation: 'status', requestId: id, event: 'status', usage: result.usage ?? [] }, dependencies.home);
173
200
  return result.output;
174
201
  },
@@ -182,7 +209,8 @@ export async function queryStatus(requestId, dependencies) {
182
209
  const result = await readResponse(response, 'status');
183
210
  if (!response.ok || result?.output?.requestId !== requestId) throw new Error(`entrorove status failed: HTTP ${response.status}; requestId=${requestId}`);
184
211
  await appendInvocation({ kind: 'remote', operation: 'status', requestId, event: 'status', usage: result.usage ?? [] }, dependencies.home);
185
- process.stdout.write(JSON.stringify(result) + '\n');
212
+ if (result.feedback) verifyAutomaticFeedback(result.feedback,requestId);
213
+ process.stdout.write(JSON.stringify(presentInvocationNotices(result,dependencies.onIdeEvent)) + '\n');
186
214
  }
187
215
 
188
216
  export async function queryFlows(dependencies) {
@@ -0,0 +1,125 @@
1
+ import { createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from 'node:crypto';
2
+ import { constants, promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { canonical, sha256 } from './local-integrity.mjs';
5
+ import { scopeFail, scopePath, scopeRoot, scopeTarget } from './local-scope-files.mjs';
6
+ import { withStateLock } from './local-state-lock.mjs';
7
+ import { snapshotPlan, snapshotVerify } from './local-execution.mjs';
8
+ import { probe } from './local-probe.mjs';
9
+ import { invokeRemoteResult } from './remote-client.mjs';
10
+
11
+ async function privateDirectory(directory) {
12
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
13
+ const stat = await fs.lstat(directory);
14
+ if (!stat.isDirectory() || stat.isSymbolicLink() || process.getuid && stat.uid !== process.getuid()) {
15
+ scopeFail('ENTROROVE_SCOPE_STATE', 'Scope signer storage must belong to the current OS account');
16
+ }
17
+ if (process.platform !== 'win32') await fs.chmod(directory, 0o700);
18
+ }
19
+ async function readPrivate(file) {
20
+ const stat = await fs.lstat(file);
21
+ if (!stat.isFile() || stat.isSymbolicLink() || process.getuid && stat.uid !== process.getuid()
22
+ || process.platform !== 'win32' && (stat.mode & 0o077)) scopeFail('ENTROROVE_SCOPE_STATE', 'Scope signer files must be private regular files');
23
+ const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
24
+ try { return await handle.readFile('utf8'); } finally { await handle.close(); }
25
+ }
26
+ async function signer(directory, identity) {
27
+ const controls = path.join(directory, 'identity'), file = path.join(controls, 'binding.json');
28
+ let binding;
29
+ try { binding = JSON.parse(await readPrivate(file)); }
30
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
31
+ if (!binding) {
32
+ const keys = generateKeyPairSync('ed25519'), publicDer = keys.publicKey.export({ type: 'spki', format: 'der' });
33
+ binding = { schemaVersion: 'entrorove.local-scope-binding/1.0', ...identity, runnerId: `local-scope-${randomUUID()}`,
34
+ repositoryId: `local-repository-${sha256(identity.repositoryRoot).slice(0, 32)}`, publicKey: publicDer.toString('base64'), keyId: sha256(publicDer) };
35
+ const stage = path.join(directory, `.signer-${randomUUID()}`); await privateDirectory(stage);
36
+ try {
37
+ await fs.writeFile(path.join(stage, 'private.pem'), keys.privateKey.export({ type: 'pkcs8', format: 'pem' }), { flag: 'wx', mode: 0o600 });
38
+ await fs.writeFile(path.join(stage, 'signer.json'), `${JSON.stringify({ schemaVersion: 'entrorove.scope-signer/1.0',
39
+ privateKeyPath: path.join(controls, 'private.pem'), keyId: binding.keyId, receiptTtlMs: 60_000 })}\n`, { flag: 'wx', mode: 0o600 });
40
+ await fs.writeFile(path.join(stage, 'binding.json'), `${JSON.stringify(binding)}\n`, { flag: 'wx', mode: 0o600 });
41
+ await fs.rename(stage, controls);
42
+ } finally { await fs.rm(stage, { recursive: true, force: true }); }
43
+ }
44
+ if (Object.keys(identity).some(field => canonical(binding[field]) !== canonical(identity[field]))) {
45
+ scopeFail('ENTROROVE_SCOPE_REPLACEMENT', 'The original local task signer, repository and path set cannot be replaced');
46
+ }
47
+ await privateDirectory(controls);
48
+ const key = createPrivateKey(await readPrivate(path.join(controls, 'private.pem')));
49
+ if (key.asymmetricKeyType !== 'ed25519' || sha256(createPublicKey(key).export({ type: 'spki', format: 'der' })) !== binding.keyId) {
50
+ scopeFail('ENTROROVE_SCOPE_STATE', 'Original scope signer key identity changed');
51
+ }
52
+ return { binding, key, signerConfigPath: path.join(controls, 'signer.json') };
53
+ }
54
+ async function remote(operation, input, dependencies) {
55
+ const { response, result } = await invokeRemoteResult({ operation, input }, dependencies);
56
+ if (!response.ok || result.output.status !== 'succeeded') {
57
+ throw Object.assign(new Error(result.output.message ?? `Scope connection blocked at ${operation}`),
58
+ { code: result.output.code ?? 'ENTROROVE_SCOPE_BLOCKED', output: result.output });
59
+ }
60
+ return result.output;
61
+ }
62
+ async function connect(identity, directory, dependencies) {
63
+ const { binding, key, signerConfigPath } = await signer(directory, identity);
64
+ const prepared = await remote('scope-runner-prepare', { flowId: identity.flowId, attemptId: randomUUID(), runnerId: binding.runnerId,
65
+ repositoryId: binding.repositoryId, publicKey: binding.publicKey, targetPaths: identity.targetPaths }, dependencies);
66
+ const enrollment = prepared.enrollment;
67
+ if (enrollment.flowId !== identity.flowId || enrollment.runnerId !== binding.runnerId || enrollment.keyId !== binding.keyId
68
+ || canonical(enrollment.targetPaths) !== canonical(identity.targetPaths)) scopeFail('ENTROROVE_SCOPE_BINDING', 'Cloud enrollment differs from the original local task signer');
69
+ await remote('scope-runner-register', { flowId: identity.flowId, enrollmentId: enrollment.enrollmentId,
70
+ signature: sign(null, Buffer.from(canonical(enrollment)), key).toString('base64url') }, dependencies);
71
+ const issued = await remote('scope-runner-challenge', { flowId: identity.flowId, receiverId: binding.runnerId, attemptId: randomUUID() }, dependencies);
72
+ const challenge = issued.challenge;
73
+ if (challenge.flowId !== identity.flowId || challenge.ownerId !== enrollment.ownerId || challenge.runnerId !== binding.runnerId
74
+ || challenge.repositoryId !== binding.repositoryId || challenge.taskDigest !== enrollment.taskDigest
75
+ || challenge.chainDigest !== enrollment.chainDigest || canonical(challenge.targetPaths) !== canonical(identity.targetPaths)) {
76
+ scopeFail('ENTROROVE_SCOPE_BINDING', 'Cloud scope challenge differs from the original account and accepted task');
77
+ }
78
+ const input = { repositoryRoot: identity.repositoryRoot, home: dependencies.home, flowId: identity.flowId,
79
+ stepId: challenge.stepId, paths: identity.targetPaths, snapshotId: `snapshot-${randomUUID()}`, importContexts: identity.importContexts };
80
+ const planned = await snapshotPlan(input), verified = await snapshotVerify(input);
81
+ if (verified.verified !== true || verified.receipt.snapshotDigest !== planned.snapshotDigest) scopeFail('ENTROROVE_SCOPE_SNAPSHOT', 'Actual copied snapshot did not verify');
82
+ const observed = await probe({ ...input, ownerId: challenge.ownerId, task: challenge.task, taskDigest: challenge.taskDigest,
83
+ chainDigest: challenge.chainDigest, repositoryId: challenge.repositoryId, challenge: challenge.challenge, signerConfigPath });
84
+ const accepted = await remote('scope-runner-attest', { flowId: identity.flowId, attestation: observed.attestation }, dependencies);
85
+ return { schemaVersion: 'entrorove.scope-connection/1.0', status: 'succeeded', flowId: identity.flowId,
86
+ runnerId: binding.runnerId, scopeEvidence: accepted.scopeEvidence, grantsExecutionAuthority: false,
87
+ nextOperation: 'workflow-gate' };
88
+ }
89
+ export async function connectScopeRunner(repositoryRoot, input, dependencies) {
90
+ if (!input || Object.keys(input).some(field => !['flowId', 'paths', 'importContexts'].includes(field))
91
+ || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(input.flowId ?? '') || !Array.isArray(input.paths) || !input.paths.length) {
92
+ scopeFail('ENTROROVE_SCOPE_INPUT', 'Expected {flowId,paths} for the original task and concrete existing source files');
93
+ }
94
+ const root = await scopeRoot(repositoryRoot), targetPaths = [...new Set(input.paths.map(scopePath))].sort();
95
+ if (targetPaths.length !== input.paths.length) scopeFail('ENTROROVE_SCOPE_INPUT', 'Scope paths must be unique');
96
+ for (const relative of targetPaths) {
97
+ const target = await scopeTarget(root, relative);
98
+ if (!target.stat.isFile()) scopeFail('ENTROROVE_SCOPE_INPUT', 'Scope connection requires concrete existing source files');
99
+ }
100
+ const importContexts = input.importContexts ?? [];
101
+ if (!Array.isArray(importContexts) || importContexts.length > 100 || new Set(importContexts.map(context => context?.sourcePrefix)).size !== importContexts.length) {
102
+ scopeFail('ENTROROVE_SCOPE_INPUT', 'Import contexts require unique source prefixes and at most 100 actual build configurations');
103
+ }
104
+ for (const context of importContexts) {
105
+ if (!context || Object.keys(context).sort().join(',') !== 'configPath,sourcePrefix') scopeFail('ENTROROVE_SCOPE_INPUT', 'Each import context requires sourcePrefix and configPath');
106
+ scopePath(context.sourcePrefix); scopePath(context.configPath);
107
+ }
108
+ const identity = { flowId: input.flowId, repositoryRoot: root, apiOrigin: dependencies.apiOrigin(), targetPaths, importContexts };
109
+ const parent = path.join(dependencies.home, '.entrorove');
110
+ await privateDirectory(parent);
111
+ const runners = path.join(parent, 'scope-runners'); await privateDirectory(runners);
112
+ const directory = path.join(runners, sha256(`${identity.apiOrigin}:${input.flowId}:${root}`)); await privateDirectory(directory);
113
+ return withStateLock(path.join(directory, 'connection'), () => connect(identity, directory, dependencies));
114
+ }
115
+
116
+ export async function runScopeConnect(repositoryRoot, input, dependencies) {
117
+ await dependencies.install();
118
+ if (!await dependencies.agreementAccepted(dependencies.home)) return dependencies.agreementRequired(dependencies.home);
119
+ try { return await connectScopeRunner(repositoryRoot, input, dependencies); }
120
+ catch (error) {
121
+ if (error.outcomeUnknown || !error.code?.startsWith('ENTROROVE_')) throw error;
122
+ return { schemaVersion: 'entrorove.scope-connection/1.0', status: 'blocked', flowId: input?.flowId,
123
+ code: error.code, message: error.message, ...(error.output ? { output: error.output } : {}) };
124
+ }
125
+ }