@lightcone-ai/daemon 0.15.50 → 0.15.52

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.
@@ -14,6 +14,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
14
14
  import { z } from 'zod';
15
15
  import { existsSync, statSync, realpathSync, mkdirSync, writeFileSync } from 'fs';
16
16
  import path from 'path';
17
+ import { homedir } from 'os';
17
18
  import { getSession, closeSession } from './chrome-pool.js';
18
19
  import { XhsAdapter } from './adapters/xhs.js';
19
20
  import { DouyinAdapter } from './adapters/douyin.js';
@@ -53,7 +54,21 @@ const ADAPTER_REGISTRY = Object.freeze({
53
54
  bilibili: BilibiliAdapter,
54
55
  });
55
56
 
56
- function getProfileDir(platform) {
57
+ // D11/D14: profile dir routing.
58
+ // Priority order:
59
+ // 1. credential_id is given AND ~/.lightcone/chrome-profiles/cred-{id} exists → use it (multi-account safe)
60
+ // 2. credential_id is given AND that path doesn't exist → return canonical cred-path anyway
61
+ // (caller will see missing-profile error explicitly; legacy fallback would silently mix accounts)
62
+ // 3. credential_id is missing (legacy single-account flow) → fall back to env var
63
+ function profileDirByCredentialId(credentialId) {
64
+ if (!credentialId) return null;
65
+ return path.join(homedir(), '.lightcone', 'chrome-profiles', `cred-${credentialId}`);
66
+ }
67
+
68
+ function getProfileDir(platform, credentialId = null) {
69
+ if (credentialId) {
70
+ return profileDirByCredentialId(credentialId);
71
+ }
57
72
  const key = PLATFORM_ENV_KEYS[platform];
58
73
  if (!key) return null;
59
74
  return process.env[key] ?? null;
@@ -74,22 +89,28 @@ function createStaticAdapter(platform) {
74
89
  return createAdapter(platform, null);
75
90
  }
76
91
 
77
- async function getAdapter(platform) {
78
- const profileDir = getProfileDir(platform);
92
+ async function getAdapter(platform, credentialId = null) {
93
+ const profileDir = getProfileDir(platform, credentialId);
79
94
  if (!profileDir) {
80
- throw new Error(`No profile dir for platform="${platform}". Has the user logged in and authorized this agent?`);
95
+ throw new Error(`No profile dir for platform="${platform}" credential_id="${credentialId ?? 'none'}". Has the user logged in and authorized this agent?`);
81
96
  }
82
- const cdp = await getSession(platform, profileDir);
97
+ // Pool session by (platform, credentialId) when credential is specified — different
98
+ // credentials on the same platform must NOT share a CDP session (D17 §6.6 isolation).
99
+ const sessionKey = credentialId ? `${platform}:cred-${credentialId}` : platform;
100
+ const cdp = await getSession(sessionKey, profileDir);
83
101
  return createAdapter(platform, cdp);
84
102
  }
85
103
 
86
- async function withPublisherProfile(platform, fn) {
87
- const profileDir = getProfileDir(platform);
104
+ async function withPublisherProfile(platform, fn, credentialId = null) {
105
+ const profileDir = getProfileDir(platform, credentialId);
88
106
  if (!profileDir) {
89
- throw new Error(`No profile dir for platform="${platform}". Has the user logged in and authorized this agent?`);
107
+ throw new Error(`No profile dir for platform="${platform}" credential_id="${credentialId ?? 'none'}". Has the user logged in and authorized this agent?`);
90
108
  }
91
- return withProfileLock(platform, profileDir, {
92
- owner: `publisher:${platform}`,
109
+ // Lock key: cred-{id} when credential-routed (D17 §6.6: same-platform multi-credential
110
+ // must not block each other). Falls back to platform name for legacy single-credential flow.
111
+ const lockKey = credentialId ? `cred-${credentialId}` : platform;
112
+ return withProfileLock(lockKey, profileDir, {
113
+ owner: `publisher:${lockKey}`,
93
114
  timeoutMs: 30_000,
94
115
  staleMs: 20 * 60 * 1000,
95
116
  }, fn);
@@ -343,8 +364,11 @@ images/video 字段填写本地绝对路径(在 agent workspace 目录下)
343
364
  const req = staticAdapter.getRequirements(content_type);
344
365
  if (!req) throw new Error(`INPUT_UNSUPPORTED_CONTENT_TYPE: ${platform} does not support ${content_type}`);
345
366
  const media = validateMedia({ platform, contentType: content_type, ...localMedia });
367
+ // D11/D14: route by credential_id when approval action carries one (multi-account safe);
368
+ // fall back to legacy env var path when missing (single-account back-compat).
369
+ const credentialIdForPublish = approvalData?.credentialId ?? approvalData?.credential_id ?? null;
346
370
  const { publishResult, healthCheck } = await withPublisherProfile(platform, async () => {
347
- const adapter = await getAdapter(platform);
371
+ const adapter = await getAdapter(platform, credentialIdForPublish);
348
372
  let publishResult = null;
349
373
  if (content_type === 'image_text') {
350
374
  publishResult = await adapter.publishImageText({ title, text, tags: tags ?? [], images: media.images });
@@ -355,8 +379,7 @@ images/video 字段填写本地绝对路径(在 agent workspace 目录下)
355
379
  }
356
380
 
357
381
  let healthCheck = null;
358
- try {
359
- healthCheck = await adapter.checkLoginStatus();
382
+ try { healthCheck = await adapter.checkLoginStatus();
360
383
  } catch (error) {
361
384
  healthCheck = {
362
385
  loggedIn: null,
@@ -111,10 +111,25 @@ function collectPayloadFlags(payload = {}) {
111
111
  ? payload.publish_history
112
112
  : (Array.isArray(payload?.publishHistory) ? payload.publishHistory : []);
113
113
 
114
+ // D11/D14: credential identity + status must reach precheck so we can fail-fast
115
+ // when the bound credential is revoked/expired (publisher_checklist requirement).
116
+ const credentialId = pickField(payload, [
117
+ 'credential_id',
118
+ 'credentialId',
119
+ 'target_credential_id',
120
+ 'targetCredentialId',
121
+ ]);
122
+ const credentialStatus = String(pickField(payload, [
123
+ 'credential_status',
124
+ 'credentialStatus',
125
+ ]) ?? '').trim().toLowerCase();
126
+
114
127
  return {
115
128
  accountId: accountId ? String(accountId).trim() : null,
116
129
  accountRole: normalizeRole(accountRole),
117
130
  autoPublishViaDelegation,
131
+ credentialId: credentialId ? String(credentialId).trim() : null,
132
+ credentialStatus: credentialStatus || null,
118
133
  contentIndicatesCollaboration,
119
134
  hasAdDisclosure,
120
135
  contentContainsAiGenerated,
@@ -151,6 +166,24 @@ function evaluatePrimaryAccountProtection(flags) {
151
166
  };
152
167
  }
153
168
 
169
+ // M4.3: publisher_checklist 凭据状态检查项 (frag.publisher.platform_checklist).
170
+ // When the action carries an explicit credential_status that is not 'active',
171
+ // fail-fast as a blocker so we don't burn a publish attempt on a revoked
172
+ // or expired credential. Active / unknown / missing status pass through —
173
+ // the actual chrome-profile validity is checked downstream when opening
174
+ // the browser session.
175
+ const ACTIVE_CREDENTIAL_STATUSES = new Set(['', 'active', 'unknown']);
176
+
177
+ function evaluateCredentialStatus(flags) {
178
+ const status = flags.credentialStatus;
179
+ if (!status) return null;
180
+ if (ACTIVE_CREDENTIAL_STATUSES.has(status)) return null;
181
+ return {
182
+ code: 'credential_invalid',
183
+ message: `credential status is "${status}" — re-authorize before publishing (D11/D14)`,
184
+ };
185
+ }
186
+
154
187
  function evaluateLabelBlockers(flags, policyScan = {}) {
155
188
  const blockers = [];
156
189
  if (policyScan.ai_label_required && flags.contentContainsAiGenerated && !flags.hasAiLabel) {
@@ -202,6 +235,9 @@ export async function runPublishPrecheck({
202
235
  const primaryProtection = evaluatePrimaryAccountProtection(flags);
203
236
  if (primaryProtection) blockers.push(primaryProtection);
204
237
 
238
+ const credentialIssue = evaluateCredentialStatus(flags);
239
+ if (credentialIssue) blockers.push(credentialIssue);
240
+
205
241
  blockers.push(...evaluateLabelBlockers(flags, policyScan));
206
242
 
207
243
  let advisory = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightcone-ai/daemon",
3
- "version": "0.15.50",
3
+ "version": "0.15.52",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -685,6 +685,12 @@ export class AgentManager {
685
685
  '__MACHINE_API_KEY__': authToken,
686
686
  '__AGENT_ID__': agentId,
687
687
  '__WORKSPACE_ID__': workspaceId ?? '',
688
+ // D11/D14: legacy per-platform PROFILE_DIR placeholders. Resolved to
689
+ // `{platform}-{userId}` for backward compatibility with single-account
690
+ // workspaces. Multi-account flows route by credential_id at the
691
+ // publisher MCP / publish-job-runner layer (see profileDirByCredentialId);
692
+ // these placeholders are kept so existing single-credential configs keep
693
+ // working without manual migration.
688
694
  '${XHS_PROFILE_DIR}': path.join(profileRoot, `xhs-${userId}`),
689
695
  '${DOUYIN_PROFILE_DIR}': path.join(profileRoot, `douyin-${userId}`),
690
696
  '${KUAISHOU_PROFILE_DIR}': path.join(profileRoot, `kuaishou-${userId}`),
@@ -1,5 +1,6 @@
1
1
  import { existsSync, statSync, realpathSync, mkdirSync, writeFileSync, renameSync, rmSync, readdirSync } from 'fs';
2
2
  import path from 'path';
3
+ import { homedir } from 'os';
3
4
  import { getSession, closeSession } from '../mcp-servers/publisher/chrome-pool.js';
4
5
  import { XhsAdapter } from '../mcp-servers/publisher/adapters/xhs.js';
5
6
  import { DouyinAdapter } from '../mcp-servers/publisher/adapters/douyin.js';
@@ -10,6 +11,12 @@ import { runPublishPrecheck } from '../mcp-servers/publisher/precheck.js';
10
11
  import { withProfileLock } from './profile-lock.js';
11
12
  import { profileDir as getBrowserProfileDir } from './browser-login.js';
12
13
 
14
+ // D11/D14: per-credential profile dir, multi-account safe.
15
+ function profileDirByCredentialId(credentialId) {
16
+ if (!credentialId) return null;
17
+ return path.join(homedir(), '.lightcone', 'chrome-profiles', `cred-${credentialId}`);
18
+ }
19
+
13
20
  const PLATFORM_ENV_KEYS = {
14
21
  xhs: 'XHS_PROFILE_DIR',
15
22
  douyin: 'DOUYIN_PROFILE_DIR',
@@ -42,7 +49,18 @@ function asObject(value) {
42
49
  return {};
43
50
  }
44
51
 
45
- export function resolveProfileDir(platform, { ownerId } = {}) {
52
+ export function resolveProfileDir(platform, { ownerId, credentialId } = {}) {
53
+ const normalizedCredentialId = normalizeText(credentialId);
54
+ if (normalizedCredentialId) {
55
+ return {
56
+ profileDir: profileDirByCredentialId(normalizedCredentialId),
57
+ source: 'credential',
58
+ envKey: null,
59
+ ownerId: normalizeText(ownerId),
60
+ credentialId: normalizedCredentialId,
61
+ };
62
+ }
63
+
46
64
  const envKey = PLATFORM_ENV_KEYS[platform];
47
65
  const envValue = normalizeText(envKey ? process.env[envKey] : null);
48
66
  if (envValue) {
@@ -51,6 +69,7 @@ export function resolveProfileDir(platform, { ownerId } = {}) {
51
69
  source: 'env',
52
70
  envKey,
53
71
  ownerId: normalizeText(ownerId),
72
+ credentialId: null,
54
73
  };
55
74
  }
56
75
 
@@ -61,6 +80,7 @@ export function resolveProfileDir(platform, { ownerId } = {}) {
61
80
  source: 'owner',
62
81
  envKey,
63
82
  ownerId: normalizedOwnerId,
83
+ credentialId: null,
64
84
  };
65
85
  }
66
86
 
@@ -69,16 +89,23 @@ export function resolveProfileDir(platform, { ownerId } = {}) {
69
89
  source: 'missing_owner',
70
90
  envKey,
71
91
  ownerId: null,
92
+ credentialId: null,
72
93
  };
73
94
  }
74
95
 
75
- export function resolveExistingProfileDir(platform, { ownerId } = {}) {
76
- const resolved = resolveProfileDir(platform, { ownerId });
96
+ export function resolveExistingProfileDir(platform, { ownerId, credentialId } = {}) {
97
+ const resolved = resolveProfileDir(platform, { ownerId, credentialId });
77
98
  if (!resolved.profileDir) {
78
- throw new Error('publish:job missing owner_id; server must include it for cross-machine profile resolution');
99
+ throw new Error('publish:job missing owner_id and credential_id; server must include at least one for profile resolution');
79
100
  }
80
101
 
81
102
  if (!existsSync(resolved.profileDir)) {
103
+ if (resolved.source === 'credential') {
104
+ throw new Error(
105
+ `Credential profile dir not found at ${resolved.profileDir}; ` +
106
+ `the credential ${resolved.credentialId} has not completed browser-login on this machine yet (D17 §6.6 implementation hard prerequisite).`
107
+ );
108
+ }
82
109
  if (resolved.source === 'env') {
83
110
  throw new Error(`Profile dir from ${resolved.envKey} not found: ${resolved.profileDir}`);
84
111
  }
@@ -102,17 +129,21 @@ function createStaticAdapter(platform) {
102
129
  return new AdapterClass(null);
103
130
  }
104
131
 
105
- async function getAdapter(platform, { ownerId } = {}) {
106
- const resolvedProfileDir = resolveExistingProfileDir(platform, { ownerId });
107
- const cdp = await getSession(platform, resolvedProfileDir);
132
+ async function getAdapter(platform, { ownerId, credentialId } = {}) {
133
+ const resolvedProfileDir = resolveExistingProfileDir(platform, { ownerId, credentialId });
134
+ // Pool by credentialId when available — different credentials must NOT share a CDP session.
135
+ const sessionKey = credentialId ? `${platform}:cred-${credentialId}` : platform;
136
+ const cdp = await getSession(sessionKey, resolvedProfileDir);
108
137
  const AdapterClass = getAdapterClass(platform);
109
138
  return new AdapterClass(cdp);
110
139
  }
111
140
 
112
- async function withPublisherProfile(platform, { ownerId } = {}, fn) {
113
- const resolvedProfileDir = resolveExistingProfileDir(platform, { ownerId });
114
- return withProfileLock(platform, resolvedProfileDir, {
115
- owner: `publisher:${platform}`,
141
+ async function withPublisherProfile(platform, { ownerId, credentialId } = {}, fn) {
142
+ const resolvedProfileDir = resolveExistingProfileDir(platform, { ownerId, credentialId });
143
+ // Lock key: cred-{id} when credential-routed (D17 §6.6); fallback to platform for legacy single-credential.
144
+ const lockKey = credentialId ? `cred-${credentialId}` : platform;
145
+ return withProfileLock(lockKey, resolvedProfileDir, {
146
+ owner: `publisher:${lockKey}`,
116
147
  timeoutMs: 30_000,
117
148
  staleMs: 20 * 60 * 1000,
118
149
  }, fn);
@@ -229,37 +260,6 @@ export async function fetchPublishJobWorkspaceFile({ serverUrl, machineApiKey, j
229
260
  return res.json();
230
261
  }
231
262
 
232
- export async function streamPublishJobVideo({
233
- serverUrl,
234
- machineApiKey,
235
- jobId,
236
- videoId,
237
- localPath,
238
- writeBinaryFile = writeFileAtomically,
239
- }) {
240
- const normalizedJobId = String(jobId ?? '').trim();
241
- if (!normalizedJobId) {
242
- throw new Error('publish job id is required to fetch video files');
243
- }
244
- const normalizedVideoId = String(videoId ?? '').trim();
245
- if (!normalizedVideoId) {
246
- throw new Error('video id is required to fetch video files');
247
- }
248
- const url = `${String(serverUrl).replace(/\/$/, '')}/internal/agent/publish-jobs/${encodeURIComponent(normalizedJobId)}/video-files/${encodeURIComponent(normalizedVideoId)}`;
249
- const res = await fetch(url, {
250
- headers: {
251
- Authorization: `Bearer ${machineApiKey}`,
252
- },
253
- });
254
- if (!res.ok) {
255
- const text = await res.text().catch(() => '');
256
- throw new Error(`publish-jobs video-files GET failed (${res.status}): ${text}`);
257
- }
258
- const data = Buffer.from(await res.arrayBuffer());
259
- writeBinaryFile(localPath, data);
260
- return localPath;
261
- }
262
-
263
263
  export function workspacePathFromMediaPath(filePath, workspaceId) {
264
264
  if (!filePath) return null;
265
265
 
@@ -498,6 +498,8 @@ function buildJobInput(job = {}) {
498
498
  payload.content_type ?? payload.contentType ?? job.content_type ?? job.contentType
499
499
  );
500
500
  const workspaceId = normalizeText(job.workspace_id ?? job.workspaceId ?? payload.workspace_id ?? payload.workspaceId);
501
+ const credentialId = normalizeText(job.credential_id ?? job.credentialId ?? payload.credential_id ?? payload.credentialId);
502
+ const accountId = normalizeText(job.account_id ?? job.accountId ?? payload.account_id ?? payload.accountId);
501
503
  const title = payload.title;
502
504
  const text = payload.text;
503
505
  const tags = ensureStringArray(payload.tags);
@@ -510,6 +512,8 @@ function buildJobInput(job = {}) {
510
512
  platform,
511
513
  contentType,
512
514
  workspaceId,
515
+ credentialId,
516
+ accountId,
513
517
  title,
514
518
  text,
515
519
  tags,
@@ -568,6 +572,8 @@ export async function runPublishJob({ serverUrl, machineApiKey, agentId, workspa
568
572
  images,
569
573
  video,
570
574
  cover,
575
+ credentialId,
576
+ accountId,
571
577
  } = buildJobInput(job);
572
578
  const ownerId = normalizeText(job?.owner_id ?? job?.ownerId);
573
579
  const jobId = normalizeText(job?.id);
@@ -576,6 +582,8 @@ export async function runPublishJob({ serverUrl, machineApiKey, agentId, workspa
576
582
  contentType: contentType || null,
577
583
  jobId: jobId ?? null,
578
584
  ownerId: ownerId ?? null,
585
+ credentialId: credentialId ?? null,
586
+ accountId: accountId ?? null,
579
587
  };
580
588
 
581
589
  if (!platform) throw attachPublishStage(new Error('publish job missing platform'), 'input_validation', stageContext);
@@ -649,9 +657,9 @@ export async function runPublishJob({ serverUrl, machineApiKey, agentId, workspa
649
657
  });
650
658
 
651
659
  currentStage = 'publish_profile';
652
- const { publishResult, healthCheck } = await withPublisherProfile(platform, { ownerId }, async () => {
660
+ const { publishResult, healthCheck } = await withPublisherProfile(platform, { ownerId, credentialId }, async () => {
653
661
  currentStage = 'adapter_session';
654
- const adapter = await getAdapter(platform, { ownerId });
662
+ const adapter = await getAdapter(platform, { ownerId, credentialId });
655
663
 
656
664
  currentStage = 'pre_publish_login';
657
665
  const prePublishLogin = await adapter.checkLoginStatus();
@@ -67,7 +67,6 @@ async function uploadBinaryFile({
67
67
  const isLarge = size >= LARGE_FILE_THRESHOLD;
68
68
 
69
69
  const doPut = async () => {
70
- const { default: fetch } = await import('node-fetch');
71
70
  const resp = await fetch(uploadUrl, {
72
71
  method: 'PUT',
73
72
  body: buf,