a2acalling 0.6.1 → 0.6.3

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/src/lib/config.js CHANGED
@@ -15,9 +15,165 @@ const CONFIG_DIR = process.env.A2A_CONFIG_DIR ||
15
15
  const CONFIG_FILE = path.join(CONFIG_DIR, 'a2a-config.json');
16
16
  const logger = createLogger({ component: 'a2a.config' });
17
17
 
18
+ function isPlainObject(value) {
19
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
20
+ }
21
+
22
+ function configValidationError(code, message, hint, data) {
23
+ const err = new Error(message);
24
+ err.code = code;
25
+ if (hint) err.hint = hint;
26
+ if (data) err.data = data;
27
+ return err;
28
+ }
29
+
30
+ function sanitizeString(value, maxLength = 200) {
31
+ return String(value || '')
32
+ .replace(/\s+/g, ' ')
33
+ .trim()
34
+ .slice(0, maxLength);
35
+ }
36
+
37
+ function validateStringArray(value, label, options = {}) {
38
+ const maxItems = Number.isFinite(options.maxItems) ? options.maxItems : 200;
39
+ const itemMaxLength = Number.isFinite(options.itemMaxLength) ? options.itemMaxLength : 160;
40
+
41
+ if (!Array.isArray(value)) {
42
+ throw configValidationError(
43
+ 'A2A_CONFIG_INVALID_ARRAY',
44
+ `Invalid ${label}: expected an array of strings`,
45
+ 'Pass an array like ["chat","search"].',
46
+ { label, received_type: typeof value }
47
+ );
48
+ }
49
+
50
+ const unique = [];
51
+ const seen = new Set();
52
+ for (const entry of value) {
53
+ if (typeof entry !== 'string') {
54
+ throw configValidationError(
55
+ 'A2A_CONFIG_INVALID_ARRAY_ITEM',
56
+ `Invalid ${label}: each item must be a string`,
57
+ 'Ensure your tier topics/goals are string arrays.',
58
+ { label, received_item_type: typeof entry }
59
+ );
60
+ }
61
+ const cleaned = sanitizeString(entry, itemMaxLength);
62
+ if (!cleaned) continue;
63
+ const key = cleaned.toLowerCase();
64
+ if (seen.has(key)) continue;
65
+ seen.add(key);
66
+ unique.push(cleaned);
67
+ if (unique.length >= maxItems) break;
68
+ }
69
+
70
+ return unique;
71
+ }
72
+
73
+ function validateTierPatch(tierName, tierConfig) {
74
+ if (!isPlainObject(tierConfig)) {
75
+ throw configValidationError(
76
+ 'A2A_CONFIG_INVALID_TIER_PATCH',
77
+ `Invalid tier config for "${tierName}": expected an object`,
78
+ 'Pass an object like { topics: [...], goals: [...] }.',
79
+ { tier: tierName, received_type: typeof tierConfig }
80
+ );
81
+ }
82
+
83
+ const out = {};
84
+
85
+ if (tierConfig.name !== undefined) {
86
+ if (typeof tierConfig.name !== 'string') {
87
+ throw configValidationError(
88
+ 'A2A_CONFIG_INVALID_TIER_NAME',
89
+ `Invalid tier name for "${tierName}": expected string`,
90
+ null,
91
+ { tier: tierName, received_type: typeof tierConfig.name }
92
+ );
93
+ }
94
+ out.name = sanitizeString(tierConfig.name, 120);
95
+ }
96
+
97
+ if (tierConfig.description !== undefined) {
98
+ if (typeof tierConfig.description !== 'string') {
99
+ throw configValidationError(
100
+ 'A2A_CONFIG_INVALID_TIER_DESCRIPTION',
101
+ `Invalid tier description for "${tierName}": expected string`,
102
+ null,
103
+ { tier: tierName, received_type: typeof tierConfig.description }
104
+ );
105
+ }
106
+ out.description = sanitizeString(tierConfig.description, 300);
107
+ }
108
+
109
+ if (tierConfig.disclosure !== undefined) {
110
+ if (typeof tierConfig.disclosure !== 'string') {
111
+ throw configValidationError(
112
+ 'A2A_CONFIG_INVALID_TIER_DISCLOSURE',
113
+ `Invalid tier disclosure for "${tierName}": expected string`,
114
+ null,
115
+ { tier: tierName, received_type: typeof tierConfig.disclosure }
116
+ );
117
+ }
118
+ out.disclosure = sanitizeString(tierConfig.disclosure, 40) || 'minimal';
119
+ }
120
+
121
+ if (tierConfig.capabilities !== undefined) {
122
+ out.capabilities = validateStringArray(tierConfig.capabilities, `${tierName}.capabilities`, {
123
+ maxItems: 100,
124
+ itemMaxLength: 120
125
+ });
126
+ }
127
+
128
+ if (tierConfig.topics !== undefined) {
129
+ out.topics = validateStringArray(tierConfig.topics, `${tierName}.topics`, {
130
+ maxItems: 200,
131
+ itemMaxLength: 160
132
+ });
133
+ }
134
+
135
+ if (tierConfig.goals !== undefined) {
136
+ out.goals = validateStringArray(tierConfig.goals, `${tierName}.goals`, {
137
+ maxItems: 200,
138
+ itemMaxLength: 160
139
+ });
140
+ }
141
+
142
+ if (tierConfig.examples !== undefined) {
143
+ out.examples = validateStringArray(tierConfig.examples, `${tierName}.examples`, {
144
+ maxItems: 20,
145
+ itemMaxLength: 120
146
+ });
147
+ }
148
+
149
+ return out;
150
+ }
151
+
152
+ function deepMerge(base, override) {
153
+ const baseIsObject = base && typeof base === 'object' && !Array.isArray(base);
154
+ const overrideIsObject = override && typeof override === 'object' && !Array.isArray(override);
155
+ if (!overrideIsObject) {
156
+ return override === undefined ? base : override;
157
+ }
158
+ const out = baseIsObject ? { ...base } : {};
159
+ for (const [key, value] of Object.entries(override)) {
160
+ const baseValue = baseIsObject ? base[key] : undefined;
161
+ const bothObjects = baseValue && typeof baseValue === 'object' && !Array.isArray(baseValue) &&
162
+ value && typeof value === 'object' && !Array.isArray(value);
163
+ out[key] = bothObjects ? deepMerge(baseValue, value) : value;
164
+ }
165
+ return out;
166
+ }
167
+
18
168
  const DEFAULT_CONFIG = {
19
- // Has the user completed onboarding?
20
- onboardingComplete: false,
169
+ onboarding: {
170
+ version: 2,
171
+ step: 'not_started', // not_started|tiers|ingress|verify|complete
172
+ tiers_confirmed: false,
173
+ ingress_confirmed: false,
174
+ verify_confirmed: false,
175
+ last_run_at: null
176
+ },
21
177
 
22
178
  // Permission tiers
23
179
  tiers: {
@@ -25,7 +181,7 @@ const DEFAULT_CONFIG = {
25
181
  name: 'Public',
26
182
  description: 'Basic networking - safe for anyone',
27
183
  capabilities: ['context-read'],
28
- topics: [],
184
+ topics: ['chat'],
29
185
  goals: [],
30
186
  disclosure: 'minimal',
31
187
  examples: ['calendar availability', 'public social posts', 'general questions']
@@ -34,19 +190,19 @@ const DEFAULT_CONFIG = {
34
190
  name: 'Friends',
35
191
  description: 'Most capabilities, no sensitive financial data',
36
192
  capabilities: ['context-read', 'calendar.read', 'email.read', 'search'],
37
- topics: [],
193
+ topics: ['chat', 'search', 'openclaw', 'a2a'],
38
194
  goals: [],
39
195
  disclosure: 'public',
40
196
  examples: ['email summaries', 'schedule meetings', 'project discussions']
41
197
  },
42
- private: {
43
- name: 'Private',
44
- description: 'Full access - only for you',
198
+ family: {
199
+ name: 'Family',
200
+ description: 'Full access - only for your inner circle',
45
201
  capabilities: ['context-read', 'calendar', 'email', 'search', 'tools', 'memory'],
46
- topics: [],
202
+ topics: ['chat', 'search', 'openclaw', 'a2a', 'tools', 'memory'],
47
203
  goals: [],
48
204
  disclosure: 'public',
49
- examples: ['financial data', 'personal notes', 'private conversations']
205
+ examples: ['deep collaboration', 'private project context', 'personal notes']
50
206
  },
51
207
  custom: {
52
208
  name: 'Custom',
@@ -99,7 +255,7 @@ class A2AConfig {
99
255
  if (fs.existsSync(CONFIG_FILE)) {
100
256
  try {
101
257
  const saved = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
102
- return { ...DEFAULT_CONFIG, ...saved };
258
+ return deepMerge(JSON.parse(JSON.stringify(DEFAULT_CONFIG)), saved);
103
259
  } catch (e) {
104
260
  logger.error('A2A config is corrupted, using defaults', {
105
261
  event: 'a2a_config_corrupt',
@@ -110,10 +266,10 @@ class A2AConfig {
110
266
  config_file: CONFIG_FILE
111
267
  }
112
268
  });
113
- return { ...DEFAULT_CONFIG };
269
+ return JSON.parse(JSON.stringify(DEFAULT_CONFIG));
114
270
  }
115
271
  }
116
- return { ...DEFAULT_CONFIG };
272
+ return JSON.parse(JSON.stringify(DEFAULT_CONFIG));
117
273
  }
118
274
 
119
275
  _save() {
@@ -133,18 +289,48 @@ class A2AConfig {
133
289
 
134
290
  // Check if onboarding is complete
135
291
  isOnboarded() {
136
- return this.config.onboardingComplete === true;
292
+ return this.config.onboarding &&
293
+ this.config.onboarding.version === 2 &&
294
+ this.config.onboarding.step === 'complete';
137
295
  }
138
296
 
139
297
  // Mark onboarding complete
140
298
  completeOnboarding() {
141
- this.config.onboardingComplete = true;
299
+ this.config.onboarding = this.config.onboarding || {};
300
+ this.config.onboarding.version = 2;
301
+ this.config.onboarding.step = 'complete';
302
+ this.config.onboarding.tiers_confirmed = true;
303
+ this.config.onboarding.ingress_confirmed = true;
304
+ this.config.onboarding.verify_confirmed = true;
305
+ this.config.onboarding.last_run_at = new Date().toISOString();
142
306
  this._save();
143
307
  }
144
308
 
145
309
  // Reset to run onboarding again
146
310
  resetOnboarding() {
147
- this.config.onboardingComplete = false;
311
+ this.config.onboarding = this.config.onboarding || {};
312
+ this.config.onboarding.version = 2;
313
+ this.config.onboarding.step = 'not_started';
314
+ this.config.onboarding.tiers_confirmed = false;
315
+ this.config.onboarding.ingress_confirmed = false;
316
+ this.config.onboarding.verify_confirmed = false;
317
+ this.config.onboarding.last_run_at = new Date().toISOString();
318
+ this._save();
319
+ }
320
+
321
+ getOnboarding() {
322
+ const ob = (this.config && this.config.onboarding && typeof this.config.onboarding === 'object')
323
+ ? this.config.onboarding
324
+ : {};
325
+ return deepMerge(DEFAULT_CONFIG.onboarding, ob);
326
+ }
327
+
328
+ setOnboarding(patch = {}) {
329
+ const current = this.getOnboarding();
330
+ const merged = deepMerge(current, patch);
331
+ merged.version = 2;
332
+ merged.last_run_at = new Date().toISOString();
333
+ this.config.onboarding = merged;
148
334
  this._save();
149
335
  }
150
336
 
@@ -154,7 +340,19 @@ class A2AConfig {
154
340
  }
155
341
 
156
342
  setTier(tierName, tierConfig) {
157
- this.config.tiers[tierName] = { ...this.config.tiers[tierName], ...tierConfig };
343
+ const id = String(tierName || '').trim();
344
+ if (!id) {
345
+ throw configValidationError(
346
+ 'A2A_CONFIG_INVALID_TIER_ID',
347
+ 'Tier name is required',
348
+ 'Use one of: public|friends|family|custom (or a non-empty custom tier id).'
349
+ );
350
+ }
351
+
352
+ const patch = validateTierPatch(id, tierConfig);
353
+ this.config.tiers = isPlainObject(this.config.tiers) ? this.config.tiers : {};
354
+ const existing = isPlainObject(this.config.tiers[id]) ? this.config.tiers[id] : {};
355
+ this.config.tiers[id] = { ...existing, ...patch };
158
356
  this._save();
159
357
  }
160
358
 
@@ -47,6 +47,7 @@ class ConversationStore {
47
47
  // Best effort - ignore on platforms without chmod support.
48
48
  }
49
49
  this._migrate();
50
+ this._ensureLatestSchema(Database);
50
51
  return this.db;
51
52
  } catch (err) {
52
53
  if (err.code === 'MODULE_NOT_FOUND') {
@@ -128,6 +129,79 @@ class ConversationStore {
128
129
  `);
129
130
  }
130
131
 
132
+ _ensureLatestSchema(Database) {
133
+ // Prototype-mode stance: do not attempt in-place migrations.
134
+ // If schema is missing required columns, back up and recreate the DB.
135
+ try {
136
+ const info = this.db.prepare('PRAGMA table_info(conversations)').all();
137
+ if (!Array.isArray(info) || info.length === 0) {
138
+ return;
139
+ }
140
+ const cols = new Set(info.map(row => row && row.name).filter(Boolean));
141
+ const required = [
142
+ 'joint_action_items',
143
+ 'collaboration_opportunity'
144
+ ];
145
+ const missing = required.filter(c => !cols.has(c));
146
+ if (missing.length === 0) {
147
+ return;
148
+ }
149
+
150
+ const backupPath = `${this.dbPath}.bak.${Date.now()}`;
151
+ logger.warn('Conversation DB schema mismatch; resetting to latest schema', {
152
+ event: 'conversation_db_schema_reset',
153
+ data: {
154
+ db_path: this.dbPath,
155
+ backup_path: backupPath,
156
+ missing_columns: missing
157
+ }
158
+ });
159
+
160
+ try { this.db.close(); } catch (_) {}
161
+ this.db = null;
162
+
163
+ try {
164
+ fs.renameSync(this.dbPath, backupPath);
165
+ } catch (err) {
166
+ try {
167
+ fs.copyFileSync(this.dbPath, backupPath);
168
+ fs.unlinkSync(this.dbPath);
169
+ } catch (err2) {
170
+ // If we can't move the old DB out of the way, keep going without resetting.
171
+ logger.error('Failed to back up conversations DB for schema reset', {
172
+ event: 'conversation_db_schema_reset_backup_failed',
173
+ error: err2,
174
+ error_code: 'CONVERSATION_DB_SCHEMA_RESET_BACKUP_FAILED',
175
+ hint: 'Check file permissions on a2a-conversations.db and ensure the process can rename/unlink it.',
176
+ data: {
177
+ db_path: this.dbPath,
178
+ backup_path: backupPath
179
+ }
180
+ });
181
+ const reopen = new Database(this.dbPath);
182
+ this.db = reopen;
183
+ return;
184
+ }
185
+ }
186
+
187
+ this.db = new Database(this.dbPath);
188
+ try {
189
+ fs.chmodSync(this.dbPath, 0o600);
190
+ } catch (err) {
191
+ // Best effort.
192
+ }
193
+ this._migrate();
194
+ } catch (err) {
195
+ // Best effort: leave existing DB in place if schema validation fails unexpectedly.
196
+ logger.error('Conversation DB schema validation failed', {
197
+ event: 'conversation_db_schema_validation_failed',
198
+ error: err,
199
+ error_code: 'CONVERSATION_DB_SCHEMA_VALIDATION_FAILED',
200
+ hint: 'If this persists, delete ~/.config/openclaw/a2a-conversations.db (prototype mode) and restart.'
201
+ });
202
+ }
203
+ }
204
+
131
205
  /**
132
206
  * Generate a conversation ID (shared between both agents)
133
207
  */
@@ -146,7 +146,7 @@ function generateDefaultManifest(contextFiles = {}) {
146
146
  const soulContent = contextFiles.soul || '';
147
147
 
148
148
  const hasContent = userContent || heartbeatContent || soulContent ||
149
- contextFiles.skill || contextFiles.memory || contextFiles.skills || contextFiles.claude;
149
+ contextFiles.memory || contextFiles.claude;
150
150
 
151
151
  if (!hasContent) {
152
152
  // Minimal starter manifest
@@ -263,21 +263,6 @@ function generateDefaultManifest(contextFiles = {}) {
263
263
  }
264
264
  }
265
265
 
266
- // Extract capability keywords from SKILL.md and installed skills
267
- const skillContent = (contextFiles.skill || '') + '\n' + (contextFiles.skills || '');
268
- if (skillContent.trim()) {
269
- const capabilityLines = skillContent
270
- .split('\n')
271
- .filter(l => l.trim().startsWith('-') || l.trim().startsWith('*'))
272
- .map(l => l.replace(/^[\s\-\*]+/, '').trim())
273
- .filter(l => l.length > 5 && l.length < 120)
274
- .slice(0, 6);
275
-
276
- capabilityLines.forEach(cap => {
277
- manifest.topics.public.discuss_freely.push({ topic: cap.slice(0, 60), detail: cap });
278
- });
279
- }
280
-
281
266
  // Extract topic keywords from memory files
282
267
  if (contextFiles.memory) {
283
268
  const memoryLines = contextFiles.memory
@@ -350,7 +335,8 @@ function readContextFiles(workspaceDir) {
350
335
  heartbeat: read('HEARTBEAT.md'),
351
336
  soul: read('SOUL.md'),
352
337
  skill: read('SKILL.md'),
353
- claude: read('CLAUDE.md')
338
+ claude: read('CLAUDE.md'),
339
+ skills: ''
354
340
  };
355
341
 
356
342
  // Scan memory/*.md
@@ -366,32 +352,6 @@ function readContextFiles(workspaceDir) {
366
352
  } catch (e) {}
367
353
  }
368
354
 
369
- // Scan installed skills from both OpenClaw and standalone paths
370
- const homeDir = process.env.HOME || '/tmp';
371
- const skillsDirs = [
372
- process.env.OPENCLAW_SKILLS || path.join(homeDir, '.openclaw', 'skills'),
373
- path.join(CONFIG_DIR, 'skills')
374
- ];
375
- const skillFragments = [];
376
- const seenSkillDirs = new Set();
377
- for (const skillsDir of skillsDirs) {
378
- if (!fs.existsSync(skillsDir) || seenSkillDirs.has(skillsDir)) continue;
379
- seenSkillDirs.add(skillsDir);
380
- try {
381
- const dirs = fs.readdirSync(skillsDir).filter(d => {
382
- try { return fs.statSync(path.join(skillsDir, d)).isDirectory(); }
383
- catch (e) { return false; }
384
- });
385
- for (const d of dirs) {
386
- try {
387
- const content = fs.readFileSync(path.join(skillsDir, d, 'SKILL.md'), 'utf8');
388
- if (content) skillFragments.push(content);
389
- } catch (e) {}
390
- }
391
- } catch (e) {}
392
- }
393
- result.skills = skillFragments.join('\n---\n');
394
-
395
355
  return result;
396
356
  }
397
357
 
@@ -22,6 +22,7 @@ const CONFIG_DIR = process.env.A2A_CONFIG_DIR ||
22
22
  const EXTERNAL_IP_CACHE_FILE = path.join(CONFIG_DIR, 'a2a-external-ip.json');
23
23
 
24
24
  const DEFAULT_SERVICES = [
25
+ 'https://ifconfig.me/ip',
25
26
  'https://api.ipify.org',
26
27
  'https://checkip.amazonaws.com/',
27
28
  'https://icanhazip.com/'
@@ -113,6 +114,7 @@ async function fetchExternalIp(options = {}) {
113
114
  ? options.services
114
115
  : DEFAULT_SERVICES;
115
116
 
117
+ const attempts = [];
116
118
  let lastError = null;
117
119
  for (const serviceUrl of services) {
118
120
  try {
@@ -124,14 +126,18 @@ async function fetchExternalIp(options = {}) {
124
126
  if (!ip) {
125
127
  throw new Error('invalid_ip');
126
128
  }
127
- return { ip, source: serviceUrl };
129
+ attempts.push({ service: serviceUrl, ok: true, statusCode: res.statusCode, ip });
130
+ return { ip, source: serviceUrl, attempts };
128
131
  } catch (err) {
129
132
  lastError = err;
133
+ attempts.push({ service: serviceUrl, ok: false, error: err && err.message ? err.message : 'request_failed' });
130
134
  }
131
135
  }
132
136
 
133
137
  const msg = lastError ? lastError.message : 'unavailable';
134
- throw new Error(`external_ip_unavailable:${msg}`);
138
+ const failure = new Error(`external_ip_unavailable:${msg}`);
139
+ failure.attempts = attempts;
140
+ throw failure;
135
141
  }
136
142
 
137
143
  /**
@@ -168,13 +174,13 @@ async function getExternalIp(options = {}) {
168
174
  }
169
175
 
170
176
  try {
171
- const { ip, source } = await fetchExternalIp({
177
+ const { ip, source, attempts } = await fetchExternalIp({
172
178
  timeoutMs: options.timeoutMs,
173
179
  services: options.services
174
180
  });
175
181
  const checkedAt = new Date(nowMs).toISOString();
176
182
  atomicWriteJson(cacheFile, { ip, checked_at: checkedAt, source });
177
- return { ip, checkedAt, source, fromCache: false, stale: false };
183
+ return { ip, checkedAt, source, fromCache: false, stale: false, attempts: Array.isArray(attempts) ? attempts : null };
178
184
  } catch (err) {
179
185
  if (cached && cached.ip) {
180
186
  const cachedIp = parseIp(cached.ip);
@@ -184,11 +190,17 @@ async function getExternalIp(options = {}) {
184
190
  checkedAt: cached.checked_at || null,
185
191
  source: cached.source || 'cache',
186
192
  fromCache: true,
187
- stale: true
193
+ stale: true,
194
+ error: err && err.message ? err.message : 'external_ip_unavailable',
195
+ attempts: err && Array.isArray(err.attempts) ? err.attempts : null
188
196
  };
189
197
  }
190
198
  }
191
- return { ip: null, error: err.message };
199
+ return {
200
+ ip: null,
201
+ error: err && err.message ? err.message : 'external_ip_unavailable',
202
+ attempts: err && Array.isArray(err.attempts) ? err.attempts : null
203
+ };
192
204
  }
193
205
  }
194
206
 
@@ -197,4 +209,3 @@ module.exports = {
197
209
  fetchExternalIp,
198
210
  getExternalIp
199
211
  };
200
-
@@ -166,24 +166,23 @@ async function resolveInviteHost(options = {}) {
166
166
  options.refreshExternalIp && isPublicIpHostname(parsed.hostname)
167
167
  );
168
168
 
169
- if (!shouldReplaceWithExternalIp) {
170
- return {
171
- host: candidateHostWithPort,
172
- source: candidateSource,
173
- originalHost: candidateHostWithPort,
174
- warnings
175
- };
176
- }
177
-
178
- const external = await getExternalIp({
179
- ttlMs,
180
- timeoutMs: options.externalIpTimeoutMs,
181
- services: options.externalIpServices,
182
- cacheFile: options.externalIpCacheFile,
183
- forceRefresh: Boolean(options.forceRefreshExternalIp)
184
- });
185
-
186
- if (external && external.ip) {
169
+ const alwaysLookupExternalIp = Boolean(options.alwaysLookupExternalIp);
170
+ const wantsExternalIp = shouldReplaceWithExternalIp || alwaysLookupExternalIp;
171
+ const warnOnExternalIpFailure = options.warnOnExternalIpFailure !== undefined
172
+ ? Boolean(options.warnOnExternalIpFailure)
173
+ : shouldReplaceWithExternalIp;
174
+
175
+ const external = wantsExternalIp
176
+ ? await getExternalIp({
177
+ ttlMs,
178
+ timeoutMs: options.externalIpTimeoutMs,
179
+ services: options.externalIpServices,
180
+ cacheFile: options.externalIpCacheFile,
181
+ forceRefresh: Boolean(options.forceRefreshExternalIp)
182
+ })
183
+ : null;
184
+
185
+ if (shouldReplaceWithExternalIp && external && external.ip) {
187
186
  const finalHost = formatHostPort(external.ip, desiredPort);
188
187
  if (finalHost !== candidateHostWithPort) {
189
188
  warnings.push(
@@ -195,17 +194,21 @@ async function resolveInviteHost(options = {}) {
195
194
  source: 'external_ip',
196
195
  originalHost: candidateHostWithPort,
197
196
  externalIp: external.ip,
197
+ externalIpInfo: external,
198
198
  warnings
199
199
  };
200
200
  }
201
201
 
202
- warnings.push(
203
- `Invite host "${candidateHostWithPort}" may not be reachable from other machines, and external IP lookup failed. Set A2A_HOSTNAME="your-public-host:port".`
204
- );
202
+ if (wantsExternalIp && (!external || !external.ip) && warnOnExternalIpFailure) {
203
+ warnings.push(
204
+ `Invite host "${candidateHostWithPort}" may not be reachable from other machines, and external IP lookup failed. Set A2A_HOSTNAME="your-public-host:port".`
205
+ );
206
+ }
205
207
  return {
206
208
  host: candidateHostWithPort,
207
209
  source: candidateSource,
208
210
  originalHost: candidateHostWithPort,
211
+ externalIpInfo: external,
209
212
  warnings
210
213
  };
211
214
  }
package/src/lib/logger.js CHANGED
@@ -129,7 +129,28 @@ class LogStore {
129
129
  } catch (err) {
130
130
  // best effort
131
131
  }
132
- this._migrate();
132
+ const ok = this._ensureSchema();
133
+ if (!ok) {
134
+ // Prototyping mode: do not attempt DB migrations; keep the old file and start fresh.
135
+ const backupPath = `${this.dbPath}.legacy.${Date.now()}`;
136
+ try {
137
+ this.db.close();
138
+ } catch (err) {
139
+ // ignore
140
+ }
141
+ fs.renameSync(this.dbPath, backupPath);
142
+ this.db = new Database(this.dbPath);
143
+ try {
144
+ fs.chmodSync(this.dbPath, 0o600);
145
+ } catch (err) {
146
+ // best effort
147
+ }
148
+ const ok2 = this._ensureSchema();
149
+ if (!ok2) {
150
+ this._dbError = 'failed_to_initialize_log_db_schema';
151
+ return null;
152
+ }
153
+ }
133
154
  this._prepareStatements();
134
155
  return this.db;
135
156
  } catch (err) {
@@ -138,7 +159,7 @@ class LogStore {
138
159
  }
139
160
  }
140
161
 
141
- _migrate() {
162
+ _ensureSchema() {
142
163
  this.db.exec(`
143
164
  CREATE TABLE IF NOT EXISTS logs (
144
165
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -167,18 +188,9 @@ class LogStore {
167
188
  `);
168
189
 
169
190
  const columns = this.db.prepare(`PRAGMA table_info(logs)`).all();
170
- const hasErrorCode = columns.some(c => c.name === 'error_code');
171
- const hasStatusCode = columns.some(c => c.name === 'status_code');
172
- const hasHint = columns.some(c => c.name === 'hint');
173
- if (!hasErrorCode) {
174
- this.db.exec(`ALTER TABLE logs ADD COLUMN error_code TEXT`);
175
- }
176
- if (!hasStatusCode) {
177
- this.db.exec(`ALTER TABLE logs ADD COLUMN status_code INTEGER`);
178
- }
179
- if (!hasHint) {
180
- this.db.exec(`ALTER TABLE logs ADD COLUMN hint TEXT`);
181
- }
191
+ const names = new Set(columns.map(c => c && c.name).filter(Boolean));
192
+ const required = ['timestamp', 'level', 'component', 'message', 'error_code', 'status_code', 'hint', 'data'];
193
+ return required.every((name) => names.has(name));
182
194
  }
183
195
 
184
196
  _prepareStatements() {