browser-workspace 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,7 @@ browser-workspace session stop SESSION_ID
20
20
 
21
21
  `--workspace` is hidden from normal CLI help. Normal sessions use `Harness`; product integrations may set an explicit workspace internally when they require isolation.
22
22
 
23
- `session stop` closes all tabs created/owned by that session while leaving unrelated browser tabs untouched.
23
+ `session stop` closes all tabs created/owned by that session while leaving unrelated browser tabs untouched. Raw session CDP cannot create targets: `Target.createTarget` is rejected and callers must use `new_tab()` so workspace/session ownership is preserved.
24
24
 
25
25
  ## Included layers
26
26
 
package/SKILL.md CHANGED
@@ -18,22 +18,24 @@ $BW_CLI status
18
18
 
19
19
  On first use, Browser Workspace bootstraps its own `.venv` automatically.
20
20
 
21
- Use one of two execution paths:
21
+ Use one session-owned execution path for orchestrated browser work:
22
22
 
23
23
  ```text
24
- known platform flow
25
- -> $BW_CLI platform run <platform> <action> [--config FILE]
26
- -> runner creates the browser session
27
- -> runner executes the platform action
28
- -> runner always stops and cleans up
29
-
30
- custom browser workflow
31
- -> $BW_CLI session start
32
- -> $BW_CLI session exec <session_id>
33
- -> $BW_CLI session stop <session_id>
24
+ start one Browser Workspace session
25
+ -> retain SESSION_ID for the whole workflow
26
+ -> platform run <platform> <action> --session-id SESSION_ID
27
+ -> inspect each result
28
+ -> on recoverable failure, diagnose and self-heal in the SAME session
29
+ -> write every successful site-specific repair back to the platform code
30
+ -> continue until the external workflow reaches a terminal outcome
31
+ -> verify the terminal external state
32
+ finally
33
+ -> stop SESSION_ID
34
34
  ```
35
35
 
36
- Do not manually create a session around a platform action unless product code intentionally owns a longer-lived browser context. There are no separate top-level `open`, `tabs`, `status`, or `screenshot` commands.
36
+ `platform run` therefore requires `--session-id` by default. If omitted, the CLI returns a structured error that recommends the `session start -> platform run --session-id -> self-heal -> session stop` workflow. `--auto-session` is an explicit one-shot escape hatch: it creates and cleans up a temporary session automatically, but **does not provide orchestrator self-heal** after failure.
37
+
38
+ There are no separate top-level `open`, `tabs`, `status`, or `screenshot` commands.
37
39
 
38
40
  Every logical browser workflow must follow:
39
41
 
@@ -110,7 +112,7 @@ A Python file may also be used with `--code-file`.
110
112
  $BW_CLI session stop <session_id>
111
113
  ```
112
114
 
113
- Stopping closes every tab owned or opened by that session—including tabs created with `new_tab()`, `bh.new_tab()`, direct `Target.createTarget`, and detected child/popup tabs—then destroys the session's Python namespace. It does not close unrelated browser tabs. Do not reuse the ID afterward.
115
+ Stopping closes every tab owned or opened by that session—including tabs created with `new_tab()`, `bh.new_tab()`, and detected child/popup tabs—then destroys the session's Python namespace. Raw `cdp("Target.createTarget", ...)` is rejected; use `new_tab()` so Browser Workspace can own and group the tab. It does not close unrelated browser tabs. Do not reuse the ID afterward.
114
116
 
115
117
  A safe shell structure is:
116
118
 
@@ -216,21 +218,41 @@ platforms/<name>/
216
218
 
217
219
  Platform rules:
218
220
 
219
- - Platform actions may use browser helpers such as `new_tab()`, `current_tab()`, `snapshot()`, `js()`, `cdp()`, uploads, downloads, and screenshots.
221
+ - Platform actions may use semantic browser helpers such as `new_tab()`, `current_tab()`, `snapshot()`, `click(selector)`, `fill_input(selector, text)`, `js()`, `cdp()`, uploads, downloads, and screenshots. `cdp()` must not create targets; `Target.createTarget` is blocked and tab creation must use `new_tab()`.
222
+ - Platform interaction code must be **semantic and element-based**. It must not use screen coordinates, viewport coordinates, `x/y` click targets, `click_at_xy()`, or coordinate calculations to interact with the page. Browser size, zoom, display resolution, and viewport geometry must not affect correctness.
220
223
  - Platform actions must **not** create/stop Browser Workspace sessions, choose a workspace, call workspace-management APIs, invoke the Browser Workspace CLI, or import session/workspace infrastructure.
224
+ - The orchestrator owns the session lifecycle for normal platform execution: start once, retain the same session across execution and recovery, and stop only after terminal success, terminal failure, or an explicit human blocker.
225
+ - On any recoverable failure, the orchestrator must inspect the live page in the same session, diagnose the failure, repair/recover, and resume from the safest point rather than restarting the whole workflow.
226
+ - **Self-heal writeback is mandatory:** if recovery discovers a site-specific selector, flow, wait condition, validation rule, or other adapter fix that makes the action work, write that repair back to `platforms/<name>/actions/` (and update platform docs/verification metadata when applicable) before declaring the workflow complete. A repair that exists only in an ad-hoc session snippet is not complete.
227
+ - After a possible external side effect (publish, send, payment, delete, submit, etc.), recovery must be verification-first. Never repeat the side effect until read-only evidence proves the previous attempt did not take effect.
221
228
  - Platform `README.md` and flow docs should describe site behavior, preconditions, side effects, and verification evidence—not Browser Workspace lifecycle mechanics.
222
- - Platform actions must remain usable in either a generic runner-owned session or a product-owned browser context.
223
- - The generic runner owns the normal lifecycle: start a default Harness session, inject browser helpers/config, execute the action, then always stop the session and close its owned tabs.
224
- - Products that require a dedicated or persistent browser context (for example Family Tutor or Agents Relay) may execute the same platform action inside product-owned infrastructure. The platform code itself stays unchanged.
225
229
  - Do not rediscover a known site's mechanics when a verified platform flow/action already exists.
226
230
 
227
- Run a platform action through the generic runner:
231
+ Normal orchestrator workflow:
232
+
233
+ ```bash
234
+ SESSION_JSON=$($BW_CLI session start --url https://example.com)
235
+ SESSION_ID=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["session_id"])' "$SESSION_JSON")
236
+
237
+ $BW_CLI platform run microsoft-teams meeting \
238
+ --session-id "$SESSION_ID" \
239
+ --config /path/to/config.json
240
+
241
+ # If it fails: inspect/self-heal using the same SESSION_ID, write the repair
242
+ # back to the platform implementation, then resume safely.
243
+
244
+ $BW_CLI session stop "$SESSION_ID"
245
+ ```
246
+
247
+ One-shot mode is explicit and is not self-healing:
228
248
 
229
249
  ```bash
230
- $BW_CLI platform run microsoft-teams meeting --config /path/to/config.json
250
+ $BW_CLI platform run microsoft-teams meeting \
251
+ --auto-session \
252
+ --config /path/to/config.json
231
253
  ```
232
254
 
233
- The runner output hides the raw session ID. It returns the platform/action result, optional workspace-extension warning, and cleanup evidence.
255
+ Use `--auto-session` only when losing the browser state after a failure is acceptable.
234
256
 
235
257
  ### Platform verification contract
236
258
 
@@ -30,18 +30,165 @@ def status():
30
30
  }
31
31
 
32
32
  def main():
33
- p=argparse.ArgumentParser(prog='browser-workspace')
34
- sp=p.add_subparsers(dest='group',required=True)
35
- sp.add_parser('status')
36
- session=sp.add_parser('session'); ssp=session.add_subparsers(dest='cmd',required=True)
37
- start=ssp.add_parser('start'); start.add_argument('--workspace', help=argparse.SUPPRESS); start.add_argument('--url')
38
- ssp.add_parser('list')
39
- ex=ssp.add_parser('exec'); ex.add_argument('session_id'); ex.add_argument('--code-file')
40
- stop=ssp.add_parser('stop'); stop.add_argument('session_id', nargs='?'); stop.add_argument('--all', action='store_true', dest='stop_all')
41
- platform=sp.add_parser('platform'); psp=platform.add_subparsers(dest='pcmd',required=True)
42
- profile=psp.add_parser('profile'); profile.add_argument('platform')
43
- source=psp.add_parser('source'); source.add_argument('platform'); source.add_argument('action'); source.add_argument('--config')
44
- run=psp.add_parser('run'); run.add_argument('platform'); run.add_argument('action'); run.add_argument('--config'); run.add_argument('--url'); run.add_argument('--session-id')
33
+ fmt=argparse.RawDescriptionHelpFormatter
34
+ p=argparse.ArgumentParser(
35
+ prog='browser-workspace',
36
+ description='Control the shared Browser Workspace Chrome, browser sessions, and platform automations.',
37
+ epilog='''Examples:
38
+ browser-workspace status
39
+ browser-workspace session start --url https://example.com
40
+ browser-workspace session list
41
+ browser-workspace session exec SESSION_ID --code-file script.py
42
+ browser-workspace session stop SESSION_ID
43
+ browser-workspace session stop --all
44
+ browser-workspace platform profile wechat-channels
45
+ browser-workspace platform run wechat-channels manage --session-id SESSION_ID --config status.json
46
+ browser-workspace platform run wechat-channels post --session-id SESSION_ID --config post.json
47
+ browser-workspace platform run wechat-channels manage --auto-session --config status.json
48
+ ''',
49
+ formatter_class=fmt,
50
+ )
51
+ sp=p.add_subparsers(dest='group',required=True,title='commands',metavar='COMMAND')
52
+
53
+ sp.add_parser(
54
+ 'status',
55
+ help='Show Browser Workspace runtime, managed Chrome, daemon, and active-session status.',
56
+ description='Show Browser Workspace runtime, managed Chrome, daemon, and active-session status.',
57
+ epilog='Example:\n browser-workspace status',
58
+ formatter_class=fmt,
59
+ )
60
+
61
+ session=sp.add_parser(
62
+ 'session',
63
+ help='Create, inspect, execute code in, and stop browser sessions.',
64
+ description='Manage Browser Workspace sessions.',
65
+ epilog='''Examples:
66
+ browser-workspace session start --url https://example.com
67
+ browser-workspace session list
68
+ browser-workspace session exec SESSION_ID --code-file script.py
69
+ cat script.py | browser-workspace session exec SESSION_ID
70
+ browser-workspace session stop SESSION_ID
71
+ browser-workspace session stop --all
72
+ ''',
73
+ formatter_class=fmt,
74
+ )
75
+ ssp=session.add_subparsers(dest='cmd',required=True,title='session commands',metavar='COMMAND')
76
+
77
+ start=ssp.add_parser(
78
+ 'start',
79
+ help='Start a browser session, optionally opening a URL.',
80
+ description='Start a browser session. The shared managed Chrome is reused; this does not launch a separate browser per session.',
81
+ epilog='''Examples:
82
+ browser-workspace session start
83
+ browser-workspace session start --url https://example.com
84
+ ''',
85
+ formatter_class=fmt,
86
+ )
87
+ start.add_argument('--workspace', help=argparse.SUPPRESS)
88
+ start.add_argument('--url', metavar='URL', help='URL to open when the session starts.')
89
+
90
+ ssp.add_parser(
91
+ 'list',
92
+ help='List currently active Browser Workspace sessions.',
93
+ description='List currently active Browser Workspace sessions and their target tabs.',
94
+ epilog='Example:\n browser-workspace session list',
95
+ formatter_class=fmt,
96
+ )
97
+
98
+ ex=ssp.add_parser(
99
+ 'exec',
100
+ help='Execute Browser Workspace Python helpers inside an existing session.',
101
+ description='Execute Python using Browser Workspace helpers in an existing session. Read code from --code-file or stdin.',
102
+ epilog='''Examples:
103
+ browser-workspace session exec SESSION_ID --code-file script.py
104
+
105
+ browser-workspace session exec SESSION_ID <<'PY'
106
+ print(page_info())
107
+ print(snapshot(interactive_only=False))
108
+ PY
109
+ ''',
110
+ formatter_class=fmt,
111
+ )
112
+ ex.add_argument('session_id', metavar='SESSION_ID', help='Session ID returned by "session start" or "session list".')
113
+ ex.add_argument('--code-file', metavar='PATH', help='Read Python code from this file instead of stdin.')
114
+
115
+ stop=ssp.add_parser(
116
+ 'stop',
117
+ help='Stop one session or all sessions and release their Browser Workspace tabs.',
118
+ description='Stop one Browser Workspace session, or all sessions with --all.',
119
+ epilog='''Examples:
120
+ browser-workspace session stop SESSION_ID
121
+ browser-workspace session stop --all
122
+ ''',
123
+ formatter_class=fmt,
124
+ )
125
+ stop.add_argument('session_id', nargs='?', metavar='SESSION_ID', help='Session ID to stop.')
126
+ stop.add_argument('--all', action='store_true', dest='stop_all', help='Stop all active Browser Workspace sessions.')
127
+
128
+ platform=sp.add_parser(
129
+ 'platform',
130
+ help='Inspect and run reusable website-specific platform automations.',
131
+ description='Inspect and run reusable Browser Workspace platform automations.',
132
+ epilog='''Examples:
133
+ browser-workspace platform profile wechat-channels
134
+ browser-workspace platform source wechat-channels post --config post.json
135
+ browser-workspace session start --url https://channels.weixin.qq.com/platform/post/list
136
+ browser-workspace platform run wechat-channels manage --session-id SESSION_ID --config status.json
137
+ browser-workspace platform run wechat-channels post --session-id SESSION_ID --config post.json
138
+ browser-workspace session stop SESSION_ID
139
+
140
+ One-shot mode (no orchestrator self-heal):
141
+ browser-workspace platform run wechat-channels manage --auto-session --config status.json
142
+ ''',
143
+ formatter_class=fmt,
144
+ )
145
+ psp=platform.add_subparsers(dest='pcmd',required=True,title='platform commands',metavar='COMMAND')
146
+
147
+ profile=psp.add_parser(
148
+ 'profile',
149
+ help='Show a platform manifest/profile: fields, media rules, and capabilities.',
150
+ description='Show the declared Browser Workspace profile for a platform.',
151
+ epilog='Example:\n browser-workspace platform profile wechat-channels',
152
+ formatter_class=fmt,
153
+ )
154
+ profile.add_argument('platform', metavar='PLATFORM', help='Platform name, for example "wechat-channels".')
155
+
156
+ source=psp.add_parser(
157
+ 'source',
158
+ help='Print the fully prepared Python source for one platform action without running it.',
159
+ description='Render the action source after injecting the optional JSON config path. No browser action is executed.',
160
+ epilog='''Examples:
161
+ browser-workspace platform source wechat-channels post --config post.json
162
+ browser-workspace platform source wechat-channels manage --config status.json
163
+ ''',
164
+ formatter_class=fmt,
165
+ )
166
+ source.add_argument('platform', metavar='PLATFORM', help='Platform name, for example "wechat-channels".')
167
+ source.add_argument('action', metavar='ACTION', help='Declared action name, for example "post" or "manage".')
168
+ source.add_argument('--config', metavar='PATH', help='JSON configuration file used to prepare the action.')
169
+
170
+ run=psp.add_parser(
171
+ 'run',
172
+ help='Run one platform action in Browser Workspace.',
173
+ description='Run a declared platform action. A caller-owned --session-id is required by default so an orchestrator can inspect failures, self-heal in the same browser state, write repairs back to platform code, and close the session only after completion. --auto-session is an explicit one-shot mode without orchestrator self-heal.',
174
+ epilog='''Examples:
175
+ browser-workspace session start --url https://channels.weixin.qq.com/platform/post/list
176
+ browser-workspace platform run wechat-channels manage --session-id SESSION_ID --config status.json
177
+ browser-workspace platform run wechat-channels post --session-id SESSION_ID --config post.json
178
+ browser-workspace session stop SESSION_ID
179
+
180
+ One-shot mode (no orchestrator self-heal):
181
+ browser-workspace platform run wechat-channels manage --auto-session --config status.json
182
+ ''',
183
+ formatter_class=fmt,
184
+ )
185
+ run.add_argument('platform', metavar='PLATFORM', help='Platform name, for example "wechat-channels".')
186
+ run.add_argument('action', metavar='ACTION', help='Declared action name, for example "post" or "manage".')
187
+ run.add_argument('--config', metavar='PATH', help='JSON configuration file for the platform action.')
188
+ run.add_argument('--url', metavar='URL', help='Optional initial URL for a temporary session.')
189
+ session_mode = run.add_mutually_exclusive_group()
190
+ session_mode.add_argument('--session-id', metavar='SESSION_ID', help='Required by default. Reuse a caller-owned session so failures can be inspected and self-healed before the session is closed.')
191
+ session_mode.add_argument('--auto-session', action='store_true', help='Explicit one-shot mode: create and clean up a temporary session automatically. No orchestrator self-heal is available after failure.')
45
192
  args=p.parse_args()
46
193
  if args.group=='status': emit(status()); return
47
194
  if args.group=='session' and args.cmd=='list':
@@ -71,5 +218,19 @@ def main():
71
218
  from platform_runner import platform_profile
72
219
  emit(platform_profile(args.platform)); return
73
220
  if args.group=='platform' and args.pcmd=='run':
74
- r=run_platform_action(args.platform,args.action,config_path=args.config,url=args.url,session_id=args.session_id); emit(r); raise SystemExit(0 if (r.get('result') or {}).get('ok',False) else 1)
221
+ if not args.session_id and not args.auto_session:
222
+ emit({
223
+ 'ok': False,
224
+ 'error': 'session_id_required',
225
+ 'message': 'platform run requires --session-id by default so the orchestrator can self-heal failures in the same browser session.',
226
+ 'suggested_workflow': [
227
+ 'browser-workspace session start --url <URL>',
228
+ 'browser-workspace platform run <PLATFORM> <ACTION> --session-id SESSION_ID [--config FILE]',
229
+ 'inspect/self-heal failures in the same SESSION_ID and write successful repairs back to platform code',
230
+ 'browser-workspace session stop SESSION_ID',
231
+ ],
232
+ 'auto_session': 'Use --auto-session only for a one-shot run. It creates and closes its own session and does not provide orchestrator self-heal.',
233
+ })
234
+ raise SystemExit(2)
235
+ r=run_platform_action(args.platform,args.action,config_path=args.config,url=args.url,session_id=args.session_id,auto_session=args.auto_session); emit(r); raise SystemExit(0 if (r.get('result') or {}).get('ok',False) else 1)
75
236
  if __name__=='__main__': main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-workspace",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Agent browser workspace with persistent sessions, tab leasing, snapshots, and platform workflows.",
5
5
  "bin": {
6
6
  "browser-workspace": "bin/browser-workspace"
@@ -102,11 +102,16 @@ def declared_actions(platform: str) -> dict[str, Path]:
102
102
  raise ValueError(f"{platform}/manifest.yaml has no actions block")
103
103
 
104
104
  actions: dict[str, Path] = {}
105
- for name, relative in re.findall(
106
- r'^\s{2}([\w-]+):\s*["\']([^"\']+)["\']\s*$',
107
- match.group("body"),
108
- flags=re.MULTILINE,
109
- ):
105
+ for line in match.group("body").splitlines():
106
+ entry = re.match(r'^\s{2}([\w-]+):\s*(.+?)\s*$', line)
107
+ if not entry:
108
+ continue
109
+ name, relative = entry.groups()
110
+ if len(relative) >= 2 and relative[0] == relative[-1] and relative[0] in {"\"", "'"}:
111
+ relative = relative[1:-1]
112
+ # Actions are simple relative file paths; comments and nested mappings are not runnable actions.
113
+ if not relative or relative.startswith(("#", "{", "[")) or " #" in relative:
114
+ continue
110
115
  path = PLATFORMS / platform / relative
111
116
  if not path.is_file():
112
117
  raise FileNotFoundError(f"declared platform action missing: {platform}/{name} -> {relative}")
@@ -146,6 +151,7 @@ def run_platform_action(
146
151
  config_path: str | None = None,
147
152
  url: str | None = None,
148
153
  session_id: str | None = None,
154
+ auto_session: bool = False,
149
155
  ) -> dict:
150
156
  path = action_path(platform, action)
151
157
  config_file = Path(config_path) if config_path else None
@@ -156,7 +162,16 @@ def run_platform_action(
156
162
  validate_platform_config(platform, action, config)
157
163
  code = prepare_action(path, config_file)
158
164
 
159
- owned_session = not bool(session_id)
165
+ if not session_id and not auto_session:
166
+ raise ValueError(
167
+ "platform run requires session_id; orchestrators should use: "
168
+ "session start -> platform run --session-id SESSION_ID -> self-heal in the same session -> session stop. "
169
+ "Use auto_session=True only for a one-shot run without orchestrator self-heal."
170
+ )
171
+ if session_id and auto_session:
172
+ raise ValueError("session_id and auto_session are mutually exclusive")
173
+
174
+ owned_session = bool(auto_session)
160
175
  started = request({"op": "start", "workspace": None, "url": url}) if owned_session else {"session_id": session_id, "workspace_supported": None}
161
176
  sid = started["session_id"]
162
177
  result = None
@@ -168,6 +183,8 @@ def run_platform_action(
168
183
  "platform": platform,
169
184
  "action": action,
170
185
  "workspace_supported": started.get("workspace_supported"),
186
+ "session_mode": "auto_session_no_self_heal" if owned_session else "caller_owned_self_heal_capable",
187
+ "self_heal_capable": not owned_session,
171
188
  "result": result,
172
189
  }
173
190
  if started.get("warning"):
@@ -6,7 +6,7 @@ import re
6
6
  from typing import Any, Optional
7
7
  from urllib.parse import parse_qs, urlparse
8
8
 
9
- MANAGER_URL = "https://channels.weixin.qq.com/platform/post"
9
+ MANAGER_URL = "https://channels.weixin.qq.com/platform/post/list"
10
10
 
11
11
  STATUS_LABELS: tuple[tuple[str, str], ...] = (
12
12
  ("审核中", "reviewing"),
@@ -30,13 +30,6 @@ def open_or_reuse_wechat(url):
30
30
  def shadow_js(expr):
31
31
  return js(SB + expr)
32
32
 
33
- def find_rect(sel):
34
- code = SB + '.querySelector("' + sel + '") ? (function(){ var r = ' + SB + '.querySelector("' + sel + '").getBoundingClientRect(); return JSON.stringify({x:Math.round(r.x+r.width/2), y:Math.round(r.y+r.height/2)}); })() : "null"'
35
- r = js(code)
36
- if r and r != "null":
37
- return json.loads(r)
38
- return None
39
-
40
33
  print("[1/6] Opening or reusing the WeChat Channels create tab...")
41
34
  open_or_reuse_wechat("https://channels.weixin.qq.com/platform/post/create")
42
35
  wait_for_load()
@@ -87,17 +80,23 @@ else:
87
80
  print(" -> Waiting for upload...")
88
81
  for attempt in range(120):
89
82
  time.sleep(5)
90
- btn_cls = shadow_js('.querySelector(".weui-desktop-btn_primary")?.classList?.contains("weui-desktop-btn_disabled") ? "disabled" : "enabled"')
91
- vid_ok = shadow_js('.querySelector(".cover-wrap, video, [class*=uploaded], [class*=video-info], .upload-success, .post-edit-wrap") ? "ready" : "wait"')
92
- if vid_ok == "ready" or btn_cls == "enabled":
83
+ upload_state = js(SB + '.innerText ? (function(text){ var match=text.match(/(?:^|\\n)(\\d{1,3})%(?:\\n|$)/); var percent=match?parseInt(match[1],10):null; var uploading=text.indexOf(\"取消上传\")>=0; return JSON.stringify({percent:percent,uploading:uploading}); })(' + SB + '.innerText) : JSON.stringify({percent:null,uploading:false})')
84
+ try:
85
+ state = json.loads(upload_state or "{}")
86
+ except Exception:
87
+ state = {}
88
+ # Do not infer completion from editor/preview presence or Publish enabled state:
89
+ # those can appear while the video is still uploading.
90
+ if state.get("percent") is None and not state.get("uploading"):
93
91
  print(" -> Upload done!")
94
92
  break
95
- if attempt % 6 == 5:
96
- pct = shadow_js('.querySelector("[class*=progress], .percent")?.textContent || "..."')
97
- print(" -> Uploading... " + str(pct) + " (" + str((attempt+1)*5) + "s)")
93
+ if attempt % 3 == 2:
94
+ pct = state.get("percent")
95
+ print(" -> Uploading... " + ((str(pct) + "%") if pct is not None else "processing") + " (" + str((attempt+1)*5) + "s)")
98
96
  else:
99
- print("WARNING: Upload timeout.")
97
+ print("ERROR: Upload did not reach a completed state before timeout.")
100
98
  capture_screenshot()
99
+ raise SystemExit(1)
101
100
  time.sleep(2)
102
101
 
103
102
  print("[3/6] Setting description...")
@@ -115,13 +114,12 @@ actual = shadow_js('.querySelector(".input-editor")?.textContent?.substring(0,50
115
114
  if actual:
116
115
  print(" -> Description: " + actual)
117
116
  else:
118
- print(" -> WARNING: Description may not have been set, trying click+type...")
119
- c = find_rect(".input-editor")
120
- if c:
121
- click_at_xy(c["x"], c["y"])
122
- time.sleep(0.5)
123
- type_text(CFG["desc"])
124
- time.sleep(0.5)
117
+ print(" -> WARNING: Description may not have been set; retrying through the semantic editor element...")
118
+ retry_desc = json.dumps(CFG["desc"])
119
+ js(SB + '.querySelector(".input-editor")?.focus()')
120
+ js(SB + '.querySelector(".input-editor").textContent = ' + retry_desc)
121
+ js(SB + '.querySelector(".input-editor").dispatchEvent(new Event("input", {bubbles:true}))')
122
+ time.sleep(0.5)
125
123
 
126
124
  if CFG["title"]:
127
125
  print("[4/6] Setting short title: " + CFG["title"])
@@ -135,28 +133,30 @@ else:
135
133
  print("[4/6] No short title, skipping...")
136
134
 
137
135
  # Scroll down to make buttons visible
138
- shadow_js('.scrollTo(0, 99999)')
139
- js('document.querySelector("wujie-app").shadowRoot.querySelector("html")?.scrollTo(0, 99999)')
140
- time.sleep(1)
141
-
142
136
  print("[5/6] Pre-action screenshot...")
143
137
  capture_screenshot()
144
138
  time.sleep(1)
145
139
 
146
- def find_btn_by_text(text):
147
- # Find a visible button by its text content (avoids matching hidden dialog buttons)
148
- r = js(SB + '.querySelectorAll(".weui-desktop-btn").forEach(function(b){}); var found=null; ' + SB + '.querySelectorAll(".weui-desktop-btn").forEach(function(b){ var rect=b.getBoundingClientRect(); if(b.textContent.trim()==="' + text + '" && rect.width>0 && !b.classList.contains("weui-desktop-btn_disabled")){ found={x:Math.round(rect.x+rect.width/2), y:Math.round(rect.y+rect.height/2)}; }}); found ? JSON.stringify(found) : "null"')
149
- if r and r != "null":
150
- return json.loads(r)
151
- return None
140
+ def click_btn_by_text(text):
141
+ # Semantic DOM click only. Never derive or use viewport/screen coordinates.
142
+ text_s = json.dumps(text)
143
+ return js(
144
+ '(function(){ var body=' + SB + '; var target=null; '
145
+ 'body.querySelectorAll(".weui-desktop-btn").forEach(function(btn){ '
146
+ 'var style=getComputedStyle(btn); '
147
+ 'if(btn.textContent.trim()===' + text_s + ' && '
148
+ '!btn.disabled && !btn.classList.contains("weui-desktop-btn_disabled") && '
149
+ 'style.display!=="none" && style.visibility!=="hidden"){ target=btn; } }); '
150
+ 'if(!target) return false; target.click(); return true; })()'
151
+ )
152
+
152
153
 
153
154
  if CFG["publish"]:
154
155
  print("[6/6] Publishing...")
155
- c = find_btn_by_text("\u53d1\u8868") # 发表
156
- if c:
157
- click_at_xy(c["x"], c["y"])
156
+ clicked = click_btn_by_text("发表")
157
+ if clicked:
158
158
  time.sleep(3)
159
- # Handle confirmation dialog
159
+ # Handle confirmation dialog semantically as well.
160
160
  shadow_js('.querySelectorAll(".weui-desktop-dialog__wrp").forEach(function(d){ if(d.style.display !== "none"){ var b = d.querySelector(".weui-desktop-btn_primary"); if(b) b.click(); }})')
161
161
  time.sleep(3)
162
162
  print(" -> Publish action submitted; verifying manager state...")
@@ -167,9 +167,8 @@ if CFG["publish"]:
167
167
  raise SystemExit(1)
168
168
  else:
169
169
  print("[6/6] Saving draft...")
170
- c = find_btn_by_text("\u4fdd\u5b58\u8349\u7a3f") # 保存草稿
171
- if c:
172
- click_at_xy(c["x"], c["y"])
170
+ clicked = click_btn_by_text("保存草稿")
171
+ if clicked:
173
172
  time.sleep(3)
174
173
  print(" -> Draft action submitted; verifying manager state...")
175
174
  action = "draft"
@@ -1,14 +1,15 @@
1
1
  platform: wechat-channels
2
- status: migrated_unverified
3
- last_verified: null
2
+ status: partially_verified
3
+ last_verified: '2026-10-04'
4
4
  actions:
5
5
  post: actions/_post.py
6
6
  manage: actions/_manage.py
7
7
  verification:
8
- status: migrated_unverified
9
- last_verified: null
10
- evidence: Existing post/shadow-DOM and manager mechanics migrated from the working
11
- post adapter. Current Browser Workspace live verification pending.
8
+ status: partially_verified
9
+ last_verified: '2026-10-04'
10
+ evidence: Live publish succeeded with creator confirmation 已发表 and manager list
11
+ count increasing to 4. Exact title/description manager-row matching remains unverified
12
+ because list rows do not expose those fields in visible text.
12
13
  url_patterns:
13
14
  - https://channels.weixin.qq.com/*
14
15
  authentication:
@@ -19,17 +20,18 @@ provenance:
19
20
  migrated_code: true
20
21
  flows:
21
22
  post:
22
- status: migrated_unverified
23
- evidence: Migrated action; current Browser Workspace live verification pending.
23
+ status: verified
24
+ evidence: Live publish succeeded on 2026-10-04; creator UI showed 已发表 and manager
25
+ list gained a new 10:33 row.
24
26
  manage:
25
- status: migrated_unverified
26
- evidence: Migrated manager action; current Browser Workspace live verification
27
- pending.
27
+ status: partially_verified
28
+ evidence: Live manager list at /platform/post/list loaded and exposed video count/timestamps;
29
+ exact title/description row matching is not yet verified.
28
30
  display_name: WeChat Channels
29
31
  aliases:
30
32
  - wechat
31
33
  - channels
32
- implementation_status: code_only_live_ui_unverified
34
+ implementation_status: live_publish_verified_manager_matching_partial
33
35
  media:
34
36
  modes:
35
37
  - video
@@ -49,12 +51,14 @@ fields:
49
51
  implemented: true
50
52
  title:
51
53
  cli: --title
52
- required: false
54
+ required: true
53
55
  type: string
54
56
  min_chars: 6
55
57
  max_chars: 16
56
58
  implemented: true
57
59
  meaning: short title
60
+ note: Live creator UI on 2026-10-04 kept Publish disabled until a valid short
61
+ title was provided.
58
62
  tags:
59
63
  cli: --tags
60
64
  required: false
@@ -116,3 +120,5 @@ notes:
116
120
  state before any deliberate retry.
117
121
  - Update/edit, comment reading, comments, and replies are intentionally unimplemented
118
122
  until their exact creator UI is observed.
123
+ - Live creator UI requires a valid short title before Publish becomes enabled; treat
124
+ title as required for post actions.
@@ -17,7 +17,7 @@ def open_or_reuse_xhs(url):
17
17
  target=tabs[0] if tabs else None
18
18
  if target:
19
19
  switch_tab(target)
20
- if page_info()["url"] != url:
20
+ if (current_tab().get("url") or "") != url:
21
21
  goto_url(url)
22
22
  else:
23
23
  new_tab(url)
@@ -86,7 +86,7 @@ def open_update_editor(row):
86
86
  return None, clicked
87
87
  for _ in range(30):
88
88
  time.sleep(.5)
89
- url=page_info()["url"]
89
+ url=current_tab().get("url") or ""
90
90
  if "/publish/update" in url:
91
91
  tab=current_tab()
92
92
  return {"update_url":url,"target_id":tab.get("targetId") or tab.get("target_id")}, None
@@ -212,7 +212,7 @@ def open_post_detail(note_id):
212
212
  ready=js('document.querySelector(".note-container") ? "yes" : "no"')
213
213
  if ready!="yes":
214
214
  return None, "post_detail_not_opened"
215
- return {"profile_url":profile,"detail_url":page_info()["url"]}, None
215
+ return {"profile_url":profile,"detail_url":current_tab().get("url") or ""}, None
216
216
 
217
217
 
218
218
  def expand_replies():
@@ -17,7 +17,7 @@ def open_or_reuse_xhs(url):
17
17
  target=tabs[0] if tabs else None
18
18
  if target:
19
19
  switch_tab(target)
20
- if page_info()["url"] != url:
20
+ if (current_tab().get("url") or "") != url:
21
21
  goto_url(url)
22
22
  else:
23
23
  new_tab(url)
@@ -26,6 +26,8 @@ def open_or_reuse_xhs(url):
26
26
  images = CFG.get("images", [CFG.get("image", "")])
27
27
  video = CFG.get("video", "")
28
28
  tags = CFG.get("tags", [])
29
+ collection = (CFG.get("collection") or "").strip()
30
+ collection_description = (CFG.get("collection_description") or "").strip()
29
31
 
30
32
  # Calculate steps dynamically
31
33
  base_steps = 6 # navigate, tab, upload, title, body, action
@@ -47,7 +49,7 @@ open_or_reuse_xhs("https://creator.xiaohongshu.com/publish/publish")
47
49
  wait_for_load()
48
50
  time.sleep(3)
49
51
 
50
- url = page_info()["url"]
52
+ url = current_tab().get("url") or ""
51
53
  if "login" in url:
52
54
  print("ERROR: Not logged in. Log in at creator.xiaohongshu.com first.")
53
55
  capture_screenshot()
@@ -82,20 +84,21 @@ if mode == "video":
82
84
  raise SystemExit(1)
83
85
 
84
86
  next_step("Waiting for video upload...")
85
- for attempt in range(120):
86
- time.sleep(5)
87
- # Check if upload is complete by looking for video preview or progress
88
- done = js('document.querySelector("[class*=video-player],[class*=videoPreview],video,.upload-success,[class*=uploadDone]") ? "ready" : "wait"')
89
- if done == "ready":
90
- print(" -> Upload done!")
87
+ for attempt in range(180):
88
+ time.sleep(1)
89
+ # A preview/video element appears immediately, even while XHS still says
90
+ # "上传中 0%". Treat the editor's completed state as authoritative.
91
+ upload_state = js('(function(){var t=document.body.innerText||""; if(/上传中\\s*\\d*%?/.test(t)||t.indexOf("取消上传")>=0)return "uploading"; if(t.indexOf("重新上传")>=0||t.indexOf("检测为高清视频")>=0)return "ready"; return "waiting"})()')
92
+ if upload_state == "ready":
93
+ print(" -> Upload processed and editor-ready!")
91
94
  break
92
- if attempt % 6 == 5:
95
+ if attempt % 15 == 14:
93
96
  pct = js('var el=document.querySelector("[class*=progress],.percent,[class*=uploadProgress]"); el ? el.textContent : "..."')
94
- print(f" -> Uploading... {pct} ({(attempt+1)*5}s)")
97
+ print(f" -> Upload state {upload_state}; {pct} ({attempt+1}s)")
95
98
  else:
96
- print("WARNING: Upload may still be processing.")
99
+ print("ERROR: video upload never reached the editor-ready state")
97
100
  capture_screenshot()
98
- time.sleep(2)
101
+ raise SystemExit(1)
99
102
 
100
103
  else:
101
104
  # Image mode — click the 2nd tab (上传图文)
@@ -153,32 +156,118 @@ time.sleep(0.5)
153
156
  if tags:
154
157
  next_step(f"Tags appended: {', '.join(tags)}")
155
158
 
159
+ def choose_collection(name, description):
160
+ if not name:
161
+ return None
162
+ opened = js('var es=Array.from(document.querySelectorAll("*")).filter(function(e){var t=(e.innerText||e.textContent||"").trim(); return e.offsetParent && e.children.length===0 && t==="选择合集"}); if(es.length){es[0].click();"yes"}else{"no"}')
163
+ if opened != "yes":
164
+ raise RuntimeError("collection chooser not found")
165
+ time.sleep(.5)
166
+ selected = js('var es=Array.from(document.querySelectorAll("*")).filter(function(e){return e.offsetParent && e.children.length===0 && (e.innerText||e.textContent||"").trim()===' + json.dumps(name) + '}); if(es.length){es[0].click();"yes"}else{"no"}')
167
+ if selected == "yes":
168
+ time.sleep(.5)
169
+ return "selected"
170
+ create = js('var es=Array.from(document.querySelectorAll("button,*")).filter(function(e){return e.offsetParent && e.children.length===0 && (e.innerText||e.textContent||"").trim()==="创建合集"}); if(es.length){es[0].click();"yes"}else{"no"}')
171
+ if create != "yes":
172
+ raise RuntimeError("collection not found and create control missing")
173
+ time.sleep(.5)
174
+ fill_input('input[placeholder="好的合集名称能吸引更多用户"]', name, clear_first=True, timeout=20)
175
+ if description:
176
+ fill_input('input[placeholder="简单介绍你的合集"],textarea[placeholder="简单介绍你的合集"]', description, clear_first=True, timeout=20)
177
+ created = js('var b=Array.from(document.querySelectorAll("button")).find(function(e){return e.offsetParent && (e.innerText||e.textContent||"").trim()==="创建并加入"}); if(b&&!b.disabled){b.click();"yes"}else{"no"}')
178
+ if created != "yes":
179
+ raise RuntimeError("create-and-join collection control unavailable")
180
+ for _ in range(20):
181
+ time.sleep(.5)
182
+ visible = js('(document.body.innerText||"").indexOf(' + json.dumps(name) + ')>=0 && (document.body.innerText||"").indexOf("创建并加入")<0 ? "yes" : "no"')
183
+ if visible == "yes":
184
+ return "created"
185
+ raise RuntimeError("collection creation was not verified")
186
+
187
+ if collection:
188
+ next_step("Joining collection: " + collection)
189
+ print(" -> Collection " + choose_collection(collection, collection_description))
190
+
191
+ def creator_manager_receipt(title):
192
+ goto_url("https://creator.xiaohongshu.com/new/note-manager")
193
+ try:
194
+ wait_for_load()
195
+ except Exception:
196
+ pass
197
+ rows = []
198
+ for _ in range(30):
199
+ time.sleep(.5)
200
+ raw = js('Array.from(document.querySelectorAll(".note-card")).map(function(c){var t=c.querySelector(".note-card__title"); return JSON.stringify({title:t?(t.innerText||t.textContent||"").trim():"", text:(c.innerText||"").trim(), impression:c.dataset.impression||""})}).join("\\n")') or ""
201
+ rows = []
202
+ for line in raw.splitlines():
203
+ try:
204
+ row = json.loads(line)
205
+ imp = json.loads(row.get("impression") or "{}")
206
+ row["note_id"] = (((imp.get("noteTarget") or {}).get("value") or {}).get("noteId"))
207
+ if row.get("title") == title:
208
+ rows.append(row)
209
+ except Exception:
210
+ pass
211
+ if rows:
212
+ break
213
+ if not rows:
214
+ return None
215
+ newest = rows[0]
216
+ text = newest.get("text") or ""
217
+ status = "reviewing" if "审核中" in text else "rejected" if "未通过" in text else "published"
218
+ return {"note_id": newest.get("note_id"), "title": title, "status": status, "duplicate_count": len(rows), "card_text": text}
219
+
156
220
  if CFG["action"] == "publish":
157
221
  next_step("Publishing...")
158
- publish_result = js('var btn=[...document.querySelectorAll("button,[role=button]")].find(function(b){return b.offsetParent && (b.innerText||b.textContent||"").trim()==="发布"}) || document.querySelector(".publishBtn,.publish-btn,button.css-k01wfk,[class*=submit]");if(btn){btn.scrollIntoView({block:"center"});btn.click();"clicked"}else{"missing"}')
222
+ # XHS can render the publish button before video processing is complete. A
223
+ # programmatic click on a disabled button is a no-op, so wait for the real
224
+ # control to become enabled before clicking it.
225
+ publish_result = "missing"
226
+ for attempt in range(180):
227
+ publish_result = js('var btn=[...document.querySelectorAll("button,[role=button]")].find(function(b){return b.offsetParent && (b.innerText||b.textContent||"").trim()==="发布"}) || document.querySelector(".publishBtn,.publish-btn,button.css-k01wfk,[class*=submit]");if(!btn){"missing"}else if(btn.disabled || btn.getAttribute("aria-disabled")==="true" || btn.classList.contains("disabled")){"disabled"}else{btn.scrollIntoView({block:"center"});btn.click();"clicked"}')
228
+ if publish_result == "clicked":
229
+ break
230
+ if attempt % 15 == 14:
231
+ print(f" -> Publish control {publish_result}; waiting for processing... ({attempt+1}s)")
232
+ time.sleep(1)
159
233
  if publish_result != "clicked":
160
234
  # XHS may expose the publish control only through the accessibility tree.
161
235
  nodes = cdp("Accessibility.getFullAXTree")["nodes"]
162
236
  target = next((n for n in nodes if (n.get("role") or {}).get("value") == "button" and (n.get("name") or {}).get("value") == "发布" and n.get("backendDOMNodeId")), None)
163
237
  if target:
164
- box = cdp("DOM.getBoxModel", backendNodeId=target["backendDOMNodeId"])["model"]["content"]
165
- x = sum(box[0::2]) / 4
166
- y = sum(box[1::2]) / 4
167
- click_at_xy(x, y)
168
- publish_result = "clicked"
169
- else:
238
+ resolved = cdp("DOM.resolveNode", backendNodeId=target["backendDOMNodeId"])["object"]
239
+ object_id = resolved.get("objectId")
240
+ if object_id:
241
+ cdp(
242
+ "Runtime.callFunctionOn",
243
+ objectId=object_id,
244
+ functionDeclaration="function(){ this.click(); return true; }",
245
+ returnByValue=True,
246
+ )
247
+ publish_result = "clicked"
248
+ if publish_result != "clicked":
170
249
  print("ERROR: publish button not found")
171
250
  capture_screenshot()
172
251
  raise SystemExit(1)
173
- verified = False
252
+ redirect_verified = False
174
253
  for _ in range(20):
175
254
  time.sleep(1)
176
255
  state = js('location.href.indexOf("publish/success")>=0 || /发布成功|成功发布/.test(document.body.innerText||"") ? "success" : "wait"')
177
256
  if state == "success":
178
- verified = True
257
+ redirect_verified = True
179
258
  break
259
+ receipt = creator_manager_receipt(CFG["title"])
260
+ if not receipt or not receipt.get("note_id"):
261
+ print("ERROR: publish was not verified in creator manager")
262
+ capture_screenshot()
263
+ raise SystemExit(2)
264
+ receipt["redirect_verified"] = redirect_verified
265
+ receipt["collection"] = collection or None
266
+ print("XHS_RECEIPT " + json.dumps(receipt, ensure_ascii=False, separators=(",", ":")))
267
+ verified = receipt["status"] in {"published", "reviewing"}
180
268
  if not verified:
181
- print("WARNING: publish click completed but success state is not yet verified")
269
+ print("ERROR: creator manager reports " + receipt["status"])
270
+ raise SystemExit(2)
182
271
  else:
183
272
  next_step("Saving as draft...")
184
273
  # Find 暂存 button by unicode
@@ -2,8 +2,8 @@ platform: xhs
2
2
  status: migrated_unverified
3
3
  last_verified: null
4
4
  actions:
5
- post: actions/_post.py
6
- manage: actions/_manage.py
5
+ post: "actions/_post.py"
6
+ manage: "actions/_manage.py"
7
7
  verification:
8
8
  status: migrated_unverified
9
9
  last_verified: null
@@ -72,12 +72,22 @@ fields:
72
72
  type: boolean
73
73
  default: false
74
74
  implemented: true
75
+ collection:
76
+ required: false
77
+ type: string
78
+ max_chars: 20
79
+ implemented: true
80
+ collection_description:
81
+ required: false
82
+ type: string
83
+ max_chars: 50
84
+ implemented: true
75
85
  capabilities:
76
86
  draft: true
77
87
  publish: true
78
88
  visibility: false
79
89
  custom_thumbnail: false
80
- series_or_collection: false
90
+ series_or_collection: true
81
91
  post_status: true
82
92
  read_comments: true
83
93
  write_comment: true
@@ -94,5 +104,4 @@ notes:
94
104
  and use that card's live xsec detail route.
95
105
  - The opened post detail view is the canonical place to read comments, write a top-level
96
106
  comment, and reply to comments.
97
- - The current adapter does not expose the Neo Vlog collection/series field even though
98
- a prior manual browser-harness session created and attached a collection.
107
+ - Optional `collection` selects an existing exact-name collection or creates and joins it when absent.
@@ -0,0 +1,34 @@
1
+ from pathlib import Path
2
+
3
+
4
+ HERE = Path(__file__).resolve().parents[1]
5
+
6
+
7
+ def test_actions_do_not_use_page_info_as_tab_url_authority():
8
+ for name in ("_post.py", "_manage.py"):
9
+ source = (HERE / "actions" / name).read_text()
10
+ assert 'page_info()["url"]' not in source
11
+
12
+
13
+ def test_actions_use_current_tab_url_for_navigation_identity():
14
+ post = (HERE / "actions" / "_post.py").read_text()
15
+ manage = (HERE / "actions" / "_manage.py").read_text()
16
+ assert '(current_tab().get("url") or "") != url' in post
17
+ assert '(current_tab().get("url") or "") != url' in manage
18
+
19
+
20
+ def test_video_readiness_uses_editor_state_not_preview():
21
+ source=(HERE/'actions'/'_post.py').read_text()
22
+ assert '上传中' in source
23
+ assert '重新上传' in source
24
+ assert 'Upload processed and editor-ready!' in source
25
+
26
+ def test_collection_and_receipt_contracts_are_present():
27
+ source=(HERE/'actions'/'_post.py').read_text()
28
+ manifest=(HERE/'manifest.yaml').read_text()
29
+ assert 'choose_collection' in source
30
+ assert 'creator_manager_receipt' in source
31
+ assert 'XHS_RECEIPT' in source
32
+ assert 'duplicate_count' in source
33
+ assert 'series_or_collection: true' in manifest
34
+ assert 'collection:' in manifest
@@ -12,11 +12,14 @@ def next_step(label):
12
12
  print(f"[{step[0]}/{total_steps}] {label}")
13
13
 
14
14
  def click_btn(text):
15
+ text_s = json.dumps(text)
15
16
  for _ in range(5):
16
- r = js('var btns=document.querySelectorAll("ytcp-button");var b=Array.from(btns).find(function(x){return x.textContent.trim()==="' + text + '"});b?JSON.stringify(b.getBoundingClientRect()):null')
17
- if r and r != "None":
18
- rect = json.loads(r)
19
- click_at_xy(int(rect["x"]+rect["width"]/2), int(rect["y"]+rect["height"]/2))
17
+ clicked = js(
18
+ '(function(){ var btns=document.querySelectorAll("ytcp-button"); '
19
+ 'var b=Array.from(btns).find(function(el){return el.textContent.trim()===' + text_s + ' && !el.disabled;}); '
20
+ 'if(!b)return false; b.click(); return true; })()'
21
+ )
22
+ if clicked:
20
23
  return True
21
24
  time.sleep(1)
22
25
  return False
package/session_client.py CHANGED
@@ -78,20 +78,30 @@ def ping():
78
78
  try: return request({'op':'ping'},ensure=False)
79
79
  except Exception: return None
80
80
 
81
+ def owner_is_running():
82
+ """An exclusive owner lock means a live daemon already exists."""
83
+ owner_path=RUNTIME_DIR/'session.lock'
84
+ with open(owner_path,'a+b') as owner:
85
+ try:
86
+ fcntl.flock(owner,fcntl.LOCK_EX|fcntl.LOCK_NB)
87
+ except BlockingIOError:
88
+ return True
89
+ finally:
90
+ try: fcntl.flock(owner,fcntl.LOCK_UN)
91
+ except OSError: pass
92
+ return False
93
+
81
94
  def ensure_daemon():
82
95
  p=ping()
83
96
  if p and p.get('ok'): return p
84
97
  RUNTIME_DIR.mkdir(parents=True,exist_ok=True)
85
- lock_path=RUNTIME_DIR/'session-daemon.lock'
86
- with open(lock_path,'a+b') as lock:
87
- fcntl.flock(lock,fcntl.LOCK_EX)
88
- p=ping()
98
+ if owner_is_running():
99
+ raise RuntimeError('browser-workspace daemon is alive but its socket is unreachable; refusing duplicate startup')
100
+ runtime_python=ensure_runtime_python()
101
+ log=open(RUNTIME_DIR/'session-daemon.log','ab',buffering=0)
102
+ subprocess.Popen([str(runtime_python),str(SOURCE/'session_daemon.py')],stdin=subprocess.DEVNULL,stdout=log,stderr=log,start_new_session=True,close_fds=True)
103
+ deadline=time.time()+15
104
+ while time.time()<deadline:
105
+ time.sleep(.1); p=ping()
89
106
  if p and p.get('ok'): return p
90
- runtime_python=ensure_runtime_python()
91
- log=open(RUNTIME_DIR/'session-daemon.log','ab',buffering=0)
92
- subprocess.Popen([str(runtime_python),str(SOURCE/'session_daemon.py')],stdin=subprocess.DEVNULL,stdout=log,stderr=log,start_new_session=True,close_fds=True)
93
- deadline=time.time()+15
94
- while time.time()<deadline:
95
- time.sleep(.1); p=ping()
96
- if p and p.get('ok'): return p
97
107
  raise RuntimeError(f'browser-workspace session daemon did not start; see {RUNTIME_DIR / "session-daemon.log"}')
package/session_daemon.py CHANGED
@@ -21,7 +21,7 @@ from browser_harness import helpers as bh
21
21
  DEFAULT_WORKSPACE = os.environ.get('BH_WORKSPACE_NAME','Harness')
22
22
  RUNTIME_DIR = Path.home()/'.config/browser-workspace/runtime'
23
23
  SOCKET_PATH = (Path(os.environ['BROWSER_WORKSPACE_SESSION_SOCKET']) if os.environ.get('BROWSER_WORKSPACE_TEST_MODE')=='1' and os.environ.get('BROWSER_WORKSPACE_SESSION_SOCKET') else RUNTIME_DIR/'session.sock')
24
- OWNER_LOCK_PATH = RUNTIME_DIR/'session-daemon.owner.lock'
24
+ OWNER_LOCK_PATH = RUNTIME_DIR/'session.lock'
25
25
  SESSIONS = {}
26
26
  SESSIONS_LOCK = threading.Lock()
27
27
  BROWSER_LOCK = threading.RLock()
@@ -84,10 +84,13 @@ class Session:
84
84
  return result
85
85
 
86
86
  def _cdp(self, method, **kwargs):
87
+ if method == 'Target.createTarget':
88
+ raise RuntimeError(
89
+ 'Target.createTarget is disabled in Browser Workspace sessions; '
90
+ 'use new_tab(url) so the tab is owned by the active session/workspace'
91
+ )
87
92
  result=bh.cdp(method, **kwargs)
88
- if method == 'Target.createTarget' and isinstance(result, dict):
89
- self._remember_target(result.get('targetId'))
90
- elif method == 'Target.closeTarget':
93
+ if method == 'Target.closeTarget':
91
94
  self.owned_target_ids.discard(kwargs.get('targetId'))
92
95
  return result
93
96
 
@@ -253,12 +256,25 @@ def stop_session(sid):
253
256
  if errors: release_error='; '.join(errors)
254
257
  return {'session_id':sid,'workspace':s.workspace,'target_id':s.target_id,'closed_tabs':closed_count,'release_error':release_error}
255
258
 
259
+ SOCKET_IDENTITY = None
260
+
261
+ def remove_owned_socket():
262
+ """Never unlink a socket replaced by another owner."""
263
+ if SOCKET_IDENTITY is None:
264
+ return
265
+ try:
266
+ st = SOCKET_PATH.lstat()
267
+ if (st.st_dev, st.st_ino) == SOCKET_IDENTITY:
268
+ SOCKET_PATH.unlink()
269
+ except FileNotFoundError:
270
+ pass
271
+
256
272
  def cleanup():
257
273
  with SESSIONS_LOCK: ids=list(SESSIONS)
258
274
  for sid in ids:
259
275
  try: stop_session(sid)
260
276
  except Exception: pass
261
- try: SOCKET_PATH.unlink(missing_ok=True)
277
+ try: remove_owned_socket()
262
278
  except Exception: pass
263
279
  atexit.register(cleanup)
264
280
 
@@ -306,6 +322,7 @@ def serve_conn(conn):
306
322
  f.write((json.dumps(result,ensure_ascii=False)+'\n').encode()); f.flush()
307
323
 
308
324
  def main():
325
+ global SOCKET_IDENTITY
309
326
  RUNTIME_DIR.mkdir(parents=True,exist_ok=True)
310
327
  try: os.chmod(RUNTIME_DIR,0o700)
311
328
  except Exception: pass
@@ -315,8 +332,9 @@ def main():
315
332
  fcntl.flock(owner,fcntl.LOCK_EX|fcntl.LOCK_NB)
316
333
  except BlockingIOError:
317
334
  owner.close(); return
335
+ # Lock ownership is required before changing the shared socket path.
318
336
  SOCKET_PATH.unlink(missing_ok=True)
319
- srv=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM); srv.bind(str(SOCKET_PATH)); os.chmod(SOCKET_PATH,0o600); srv.listen(32)
337
+ srv=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM); srv.bind(str(SOCKET_PATH)); os.chmod(SOCKET_PATH,0o600); SOCKET_IDENTITY=(SOCKET_PATH.stat().st_dev, SOCKET_PATH.stat().st_ino); srv.listen(32)
320
338
  try:
321
339
  while True:
322
340
  conn,_=srv.accept(); threading.Thread(target=serve_conn,args=(conn,),daemon=True).start()